diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b29454..64eb56e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -170,6 +170,7 @@ jobs: sed -i "s#easy-haproxy:[a-zA-Z0-9\.-]*#easy-haproxy:$TAG#g" deploy/kubernetes/easyhaproxy-*.yml sed -i "s#easy-haproxy/[a-zA-Z0-9\.-]*/#easy-haproxy/$TAG/#g" docs/kubernetes.md + sed -i "s#easy-haproxy:[a-zA-Z0-9\.-]*#easy-haproxy:$TAG#g" docs/swarm.md sed -i "s#appVersion: \"[a-zA-Z0-9\.-]*\"#appVersion: \"$TAG\"#g" helm/easyhaproxy/Chart.yaml find examples -type f -name '*.yml' -exec sed -i "s#\(byjg/easy-haproxy:\)[a-zA-Z0-9\.-]*#\1$TAG#g" {} \; -print diff --git a/.gitignore b/.gitignore index 1610fa1..a32f7e6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,12 @@ __pycache__ .pytest_cache *.pyc .env +/examples/static/conf/config.yml +/examples/docker/certs/haproxy/.place_holder_cert.pem +/examples/static/host1.local.pem +/examples/swarm/certs/host1.local.pem +/examples/docker/host2.local.pem +/examples/swarm/certs/host2.local.pem +/examples/docker/jwt_private.pem +/examples/docker/jwt_pubkey.pem +/examples/docker/cloudflare_ips.lst diff --git a/Makefile b/Makefile index 0b7e07f..4c9c278 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,4 @@ build: .PHONY: test test: - pytest tests/ + cd src/ && pytest tests/ -vv diff --git a/README.md b/README.md index e5b916a..b0122b5 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ Detailed configuration guides for advanced setups: - [Container Labels](docs/container-labels.md) - Configure Docker/Swarm containers with labels - [Environment Variables](docs/environment-variable.md) - Configure EasyHAProxy behavior - [Volumes](docs/volumes.md) - Map volumes for certificates, config, and custom files +- [Plugins](docs/plugins.md) - Extend HAProxy with plugins ([Development Guide](docs/plugin-development.md)) + - [JWT Validator](docs/Plugins/jwt-validator.md) - JWT authentication validation + - [FastCGI](docs/Plugins/fastcgi.md) - PHP-FPM and FastCGI application support + - [Cloudflare](docs/Plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN + - [IP Whitelist](docs/Plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges + - [Deny Pages](docs/Plugins/deny-pages.md) - Block access to specific paths + - [Cleanup](docs/Plugins/cleanup.md) - Automatic cleanup of temporary files - [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.) - [Limitations](docs/limitations.md) - Important limitations and considerations diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..008a242 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,350 @@ +# EasyHAProxy Release Guide + +This guide explains how to create a new release of EasyHAProxy, including Docker images, Helm charts, and documentation updates. + +## Table of Contents + +- [Overview](#overview) +- [Prerequisites](#prerequisites) +- [Release Process](#release-process) +- [Automated Release (Recommended)](#automated-release-recommended) +- [Manual Release](#manual-release) +- [Helm Chart Release](#helm-chart-release) +- [Post-Release Checklist](#post-release-checklist) +- [Troubleshooting](#troubleshooting) + +## Overview + +The EasyHAProxy release process uses GitHub Actions to automatically: +- Run tests +- Build multi-architecture Docker images (amd64, arm64) +- Publish Docker images to Docker Hub +- Update Helm chart versions +- Publish Helm charts +- Update documentation + +## Prerequisites + +Before creating a release, ensure you have: + +1. **Permissions:** + - Write access to the GitHub repository + - Docker Hub credentials (for maintainers) + - Access to GitHub secrets (for CI/CD) + +2. **Local Setup:** + - Git configured with your credentials + - Docker installed (for local testing) + - Python 3.x with pytest (for running tests) + +3. **Repository Secrets (for maintainers):** + - `DOCKER_REGISTRY`: Docker Hub registry URL + - `DOCKER_REGISTRY_USER`: Docker Hub username + - `DOCKER_REGISTRY_TOKEN`: Docker Hub access token + - `DOC_TOKEN`: GitHub token for documentation updates + +## Release Process + +### Version Numbering + +EasyHAProxy follows [Semantic Versioning](https://semver.org/): + +- **MAJOR.MINOR.PATCH** (e.g., `4.6.0`) + - **MAJOR**: Breaking changes or major architectural updates + - **MINOR**: New features, plugin additions, backward-compatible changes + - **PATCH**: Bug fixes, documentation updates, minor improvements + +**Current Version:** `4.6.0` (as of Chart.yaml) + +## Automated Release (Recommended) + +The automated release process is triggered by pushing a semantic version tag. + +### Step 1: Prepare the Release + +1. **Ensure all changes are committed and pushed:** + ```bash + git status + git add . + git commit -m "Prepare release X.Y.Z" + git push origin master + ``` + +2. **Run tests locally:** + ```bash + cd src/ + pytest tests/ -vv + ``` + +3. **Build and test Docker image locally:** + ```bash + make build + # Or manually: + docker build -t byjg/easy-haproxy:local -f build/Dockerfile . + ``` + +### Step 2: Create and Push a Release Tag + +1. **Create a new semantic version tag:** + ```bash + # For a new minor version (new features) + git tag 4.7.0 + + # For a patch version (bug fixes) + git tag 4.6.1 + + # For a major version (breaking changes) + git tag 5.0.0 + ``` + +2. **Push the tag to GitHub:** + ```bash + git push origin 4.7.0 + ``` + +3. **Monitor the GitHub Actions workflow:** + - Go to: https://github.com/byjg/docker-easy-haproxy/actions + - Watch the "Docker" workflow progress + - Verify all jobs complete successfully: + - ✅ Test + - ✅ Build (multi-arch) + - ✅ Helm + - ✅ HelmDeploy + - ✅ Documentation + +### Step 3: What Happens Automatically + +When you push a semantic version tag, GitHub Actions will: + +1. **Run Tests** (`Test` job): + - Install Python dependencies + - Run pytest on all tests + +2. **Build Multi-Arch Docker Images** (`Build` job): + - Build for `linux/amd64` and `linux/arm64` + - Tag image with version number (e.g., `byjg/easy-haproxy:4.7.0`) + - Push to Docker Hub + +3. **Update Versions** (`Helm` job): + - Update `helm/easyhaproxy/Chart.yaml`: + - `appVersion`: Set to new version (e.g., `4.7.0`) + - `version`: Auto-increment patch version (e.g., `0.1.9` → `0.1.10`) + - Update all version references in: + - `deploy/docker/docker-compose.yml` + - `deploy/kubernetes/easyhaproxy-*.yml` + - `docs/kubernetes.md` + - `examples/*/*.yml` + - Commit and push changes with message: `[skip ci] Update from X.Y.Z to A.B.C` + +4. **Publish Helm Chart** (`HelmDeploy` job): + - Package Helm chart + - Publish to Helm repository at https://opensource.byjg.com/helm/ + +5. **Update Documentation** (`Documentation` job): + - Publish documentation updates + +### Step 4: Verify the Release + +1. **Check Docker Hub:** + ```bash + docker pull byjg/easy-haproxy:4.7.0 + docker images | grep easy-haproxy + ``` + +2. **Verify Helm chart:** + ```bash + helm repo add byjg https://opensource.byjg.com/helm + helm repo update + helm search repo easyhaproxy + ``` + +3. **Create GitHub Release:** + - Go to: https://github.com/byjg/docker-easy-haproxy/releases/new + - Select the tag you created + - Generate release notes + - Add highlights of changes + - Publish release + +## Manual Release + +For emergency releases or when CI/CD is unavailable. + +### Manual Docker Build (Multi-Arch) + +1. **Set up environment:** + ```bash + export DOCKER_USERNAME=your-username + export DOCKER_PASSWORD=your-token + export DOCKER_REGISTRY=docker.io + export VERSIONS="4.7.0" + ``` + +2. **Run multi-arch build:** + ```bash + ./build-multiarch.sh + ``` + + This script uses `buildah` and `podman` to create multi-architecture images. + +### Manual Helm Chart Update + +1. **Update Chart.yaml:** + ```bash + cd helm/easyhaproxy/ + + # Update appVersion + sed -i 's/appVersion: ".*"/appVersion: "4.7.0"/' Chart.yaml + + # Increment chart version + # From: version: 0.1.9 + # To: version: 0.1.10 + nano Chart.yaml + ``` + +2. **Package and publish Helm chart:** + ```bash + helm package helm/easyhaproxy/ + # Follow your Helm repository's publishing process + ``` + +## Helm Chart Release + +The Helm chart version is automatically managed by CI/CD, but you can manually control it: + +### Helm Chart Version Strategy + +- **Chart version** (`version` in Chart.yaml): + - Auto-incremented by CI/CD (patch version) + - Format: `0.1.X` where X increments with each Docker release + - Manual override: Edit Chart.yaml before tagging + +- **App version** (`appVersion` in Chart.yaml): + - Set to Docker image version (e.g., `4.7.0`) + - Automatically updated by CI/CD + +### Current Helm Chart + +- **Chart Version:** `0.1.9` +- **App Version:** `4.6.0` +- **Repository:** https://opensource.byjg.com/helm/ + +## Post-Release Checklist + +After a successful release: + +- [ ] Verify Docker image on Docker Hub +- [ ] Test Docker image: `docker run byjg/easy-haproxy:X.Y.Z --version` +- [ ] Verify Helm chart availability +- [ ] Test Helm installation +- [ ] Create GitHub Release with changelog +- [ ] Update project README if needed +- [ ] Announce release (if major/minor) +- [ ] Update dependent projects (if applicable) + +## Troubleshooting + +### Build Fails + +**Problem:** GitHub Actions build job fails + +**Solutions:** +1. Check test output in GitHub Actions logs +2. Run tests locally: `cd src/ && pytest tests/ -vv` +3. Fix failing tests and push changes +4. Delete and recreate tag: + ```bash + git tag -d 4.7.0 + git push origin :refs/tags/4.7.0 + git tag 4.7.0 + git push origin 4.7.0 + ``` + +### Docker Push Fails + +**Problem:** Cannot push to Docker Hub + +**Solutions:** +1. Verify Docker Hub credentials in GitHub secrets +2. Check Docker Hub token permissions +3. Ensure image name matches: `byjg/easy-haproxy` + +### Helm Chart Not Published + +**Problem:** Helm chart doesn't appear in repository + +**Solutions:** +1. Check `HelmDeploy` job logs in GitHub Actions +2. Verify `DOC_TOKEN` secret is valid +3. Wait a few minutes for chart to propagate +4. Clear Helm cache: `helm repo update` + +### Version Not Updated + +**Problem:** Version references not updated in docs/examples + +**Solutions:** +1. Check `Helm` job logs for sed command errors +2. Verify commit was pushed with `[skip ci]` message +3. Manually update version references if needed: + ```bash + find examples -type f -name '*.yml' -exec sed -i "s/\(byjg\/easy-haproxy:\)[0-9\.]*/\1X.Y.Z/g" {} \; + ``` + +### Multi-Arch Build Issues + +**Problem:** ARM64 build fails + +**Solutions:** +1. Verify QEMU is set up in GitHub Actions +2. Check build logs for architecture-specific errors +3. Test locally with Docker Buildx: + ```bash + docker buildx create --use + docker buildx build --platform linux/amd64,linux/arm64 -t test . + ``` + +## Quick Reference + +### Commands + +```bash +# Local build +make build + +# Run tests +cd src/ && pytest tests/ -vv + +# Create release tag +git tag 4.7.0 && git push origin 4.7.0 + +# Pull specific version +docker pull byjg/easy-haproxy:4.7.0 + +# Install Helm chart +helm install easyhaproxy byjg/easyhaproxy --version 0.1.10 + +# Check Helm chart info +helm show chart byjg/easyhaproxy +``` + +### Important URLs + +- **GitHub Repository:** https://github.com/byjg/docker-easy-haproxy +- **Docker Hub:** https://hub.docker.com/r/byjg/easy-haproxy +- **Helm Repository:** https://opensource.byjg.com/helm/ +- **Documentation:** https://opensource.byjg.com/devops/docker-easy-haproxy/ +- **GitHub Actions:** https://github.com/byjg/docker-easy-haproxy/actions + +### Version History + +| Version | Release Date | Type | Highlights | +|---------|-------------|------|------------| +| 4.6.0 | 2024-11-27 | Minor | FastCGI plugin, JWT enhancements | +| 4.5.0 | 2024-XX-XX | Minor | Previous release | +| ... | ... | ... | ... | + +--- + +**Need Help?** +- Open an issue: https://github.com/byjg/docker-easy-haproxy/issues +- Check documentation: https://opensource.byjg.com/devops/docker-easy-haproxy/ diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 96aaddd..26df7b8 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: easyhaproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/docs/Plugins/cleanup.md b/docs/Plugins/cleanup.md new file mode 100644 index 0000000..d1d9ef4 --- /dev/null +++ b/docs/Plugins/cleanup.md @@ -0,0 +1,84 @@ +--- +sidebar_position: 21 +--- + +# Cleanup Plugin + +**Type:** Global Plugin +**Runs:** Once per discovery cycle + +## Overview + +The Cleanup plugin performs cleanup tasks during each discovery cycle, such as removing old temporary files. + +## Why Use It + +Prevents disk space issues by automatically cleaning up temporary files created by EasyHAProxy. + +## Configuration Options + +| Option | Description | Default | +|----------------------|----------------------------------------------|---------| +| `enabled` | Enable/disable plugin | `true` | +| `max_idle_time` | Maximum age in seconds before deleting files | `300` | +| `cleanup_temp_files` | Enable temp file cleanup | `true` | + +## Configuration Examples + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + max_idle_time: 600 + cleanup_temp_files: true +``` + +### Environment Variables + +Configure the Cleanup plugin globally: + +| Environment Variable | Config Key | Type | Default | Description | +|-------------------------------------------------|----------------------|----------|---------|----------------------------------------------| +| `EASYHAPROXY_PLUGINS_ENABLED` | - | string | - | Enable cleanup plugin (value: `cleanup`) | +| `EASYHAPROXY_PLUGIN_CLEANUP_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin | +| `EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME` | `max_idle_time` | integer | `300` | Maximum age in seconds before deleting files | +| `EASYHAPROXY_PLUGIN_CLEANUP_CLEANUP_TEMP_FILES` | `cleanup_temp_files` | boolean | `true` | Enable temp file cleanup | + +**Note:** This is a global plugin - configuration applies to the entire system. + +### Custom Idle Time (1 hour) + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + enabled: true + max_idle_time: 3600 # 1 hour +``` + +## How It Works + +The cleanup plugin: +- Runs once during each discovery cycle +- Scans temporary directories for old files +- Removes files older than `max_idle_time` seconds +- Helps maintain disk space efficiency + +## Important Notes + +- This is a **global plugin** - it runs once per discovery cycle, not per domain +- Does not generate HAProxy configuration +- Performs maintenance operations in the background +- Safe to enable in production environments + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Environment Variables Reference](../environment-variable.md) +- [Static Configuration Reference](../static.md) diff --git a/docs/Plugins/cloudflare.md b/docs/Plugins/cloudflare.md new file mode 100644 index 0000000..8949c1e --- /dev/null +++ b/docs/Plugins/cloudflare.md @@ -0,0 +1,129 @@ +--- +sidebar_position: 18 +--- + +# Cloudflare Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The Cloudflare plugin restores the original visitor IP address when requests come through Cloudflare's CDN. The plugin includes **built-in Cloudflare IP ranges** that are automatically written to the IP list file - no manual configuration required! + +## Why Use It + +Cloudflare replaces the visitor's IP with its own. This plugin restores the original IP from the `CF-Connecting-IP` header. + +## Configuration Options + +| Option | Description | Default | +|-------------------|------------------------------------------|-----------------------------------| +| `enabled` | Enable/disable plugin | `true` | +| `use_builtin_ips` | Use built-in Cloudflare IP ranges | `true` | +| `ip_list_path` | Path to Cloudflare IP list | `/etc/haproxy/cloudflare_ips.lst` | + +## Configuration Examples + +### Docker/Docker Compose (Basic - Uses Built-in IPs) + +```yaml +services: + myapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: cloudflare +# Built-in Cloudflare IPs are automatically used - no additional configuration needed! +``` + +### Docker/Docker Compose (Custom IP List) + +If you want to use your own IP list file instead of the built-in ranges: + +```yaml +labels: + easyhaproxy.http.plugins: cloudflare + easyhaproxy.http.plugin.cloudflare.use_builtin_ips: false + easyhaproxy.http.plugin.cloudflare.ip_list_path: /custom/path/cf_ips.lst +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "cloudflare" + easyhaproxy.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" +spec: + rules: + - host: example.com + http: + paths: + - path: / + backend: + service: + name: myapp + port: + number: 80 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + config: + cloudflare: + enabled: true + use_builtin_ips: true # Uses built-in Cloudflare IPs (default) +``` + +### Environment Variables + +Configure Cloudflare plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|-------------------------------------------------|-------------------|----------|-----------------------------------|---------------------------------------| +| `EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS` | `use_builtin_ips` | boolean | `true` | Use built-in Cloudflare IP ranges | +| `EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH` | `ip_list_path` | string | `/etc/haproxy/cloudflare_ips.lst` | Path to Cloudflare IP list file | + +**Note:** Environment variables set defaults for ALL domains. To enable/disable per-domain, use container labels or Kubernetes annotations. + +## Generated HAProxy Configuration + +```haproxy +# 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 +``` + +## Built-in Cloudflare IP Ranges + +The plugin includes the current Cloudflare IP ranges (22 ranges total): + +**IPv4 Ranges (15):** +- 173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22, 103.31.4.0/22 +- 141.101.64.0/18, 108.162.192.0/18, 190.93.240.0/20, 188.114.96.0/20 +- 197.234.240.0/22, 198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13 +- 104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22 + +**IPv6 Ranges (7):** +- 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32 +- 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32 + +These ranges are automatically written to `/etc/haproxy/cloudflare_ips.lst` during each discovery cycle. + +## Important Notes + +- ✅ **No manual configuration required** - Built-in Cloudflare IPs are included! +- The plugin runs once per domain during the discovery cycle +- IP list file is automatically created and updated +- To update Cloudflare IPs in the future, simply update the plugin source code and rebuild + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/Plugins/deny-pages.md b/docs/Plugins/deny-pages.md new file mode 100644 index 0000000..9c9bf2c --- /dev/null +++ b/docs/Plugins/deny-pages.md @@ -0,0 +1,129 @@ +--- +sidebar_position: 20 +--- + +# Deny Pages Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The Deny Pages plugin blocks access to specific paths for a domain, returning a configurable HTTP status code. + +## Why Use It + +Protect admin panels, internal APIs, or debugging endpoints from public access. + +## Configuration Options + +| Option | Description | Default | +|---------------|----------------------------------------|------------| +| `enabled` | Enable/disable plugin | `true` | +| `paths` | Comma-separated list of paths to block | (required) | +| `status_code` | HTTP status code to return | `403` | + +## Configuration Examples + +### Docker/Docker Compose (Basic) + +```yaml +services: + webapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/private,/debug + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### WordPress Protection + +```yaml +labels: + easyhaproxy.http.host: wordpress.example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /wp-admin,/wp-login.php,/.env + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/admin,/private" + easyhaproxy.plugin.deny_pages.status_code: "403" +spec: + rules: + - host: example.com + http: + paths: + - path: / + backend: + service: + name: webapp + port: + number: 80 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: example.com + port: 80 + container: webapp:80 + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: /admin,/private,/debug + status_code: 403 +``` + +### Multiple Plugins (with Cloudflare) + +```yaml +labels: + easyhaproxy.http.host: secure-app.com + easyhaproxy.http.plugins: cloudflare,deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/config + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +### Environment Variables + +Configure Deny Pages plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|---------------------------------------------|---------------|---------|---------|----------------------------------------| +| `EASYHAPROXY_PLUGIN_DENY_PAGES_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_DENY_PAGES_PATHS` | `paths` | string | - | Comma-separated list of paths to block | +| `EASYHAPROXY_PLUGIN_DENY_PAGES_STATUS_CODE` | `status_code` | integer | `403` | HTTP status code to return | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. + +## Generated HAProxy Configuration + +```haproxy +# Deny Pages - Block specific paths +acl denied_path path_beg /admin /private /debug +http-request deny deny_status 404 if denied_path +``` + +## Important Notes + +- The plugin runs once per domain during the discovery cycle +- Path matching uses `path_beg` (prefix matching), so `/admin` blocks `/admin/*` too +- Consider using `404` instead of `403` to hide the existence of blocked paths +- Works well in combination with other security plugins + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/Plugins/fastcgi.md b/docs/Plugins/fastcgi.md new file mode 100644 index 0000000..28db516 --- /dev/null +++ b/docs/Plugins/fastcgi.md @@ -0,0 +1,177 @@ +--- +sidebar_position: 17 +--- + +# FastCGI Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The FastCGI plugin configures HAProxy to communicate with PHP-FPM and other FastCGI applications. It automatically generates the necessary HAProxy `fcgi-app` configuration that defines CGI parameters for proper PHP-FPM communication. + +## Why Use It + +Automatically generates HAProxy `fcgi-app` configuration that defines required CGI parameters for PHP-FPM communication without manual HAProxy configuration. + +## Configuration Options + +| Option | Description | Default | +|-------------------|-----------------------------------------|------------------------------------| +| `enabled` | Enable/disable plugin | `true` | +| `document_root` | Document root path | `/var/www/html` | +| `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | +| `index_file` | Default index file | `index.php` | +| `path_info` | Enable PATH_INFO support | `true` | +| `custom_params` | Dictionary of custom FastCGI parameters | (optional) | + +## Configuration Examples + +### Docker/Docker Compose (TCP connection) + +```yaml +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 9000 + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + volumes: + - ./app:/var/www/html +``` + +### Docker/Docker Compose (Unix socket) + +```yaml +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.socket: /run/php/php-fpm.sock + easyhaproxy.http.proto: fcgi + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + volumes: + - ./app:/var/www/html + - /run/php:/run/php +``` + +### Custom Document Root and Index File + +```yaml +labels: + easyhaproxy.http.plugins: fastcgi + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp/public + easyhaproxy.http.plugin.fastcgi.index_file: app.php + easyhaproxy.http.plugin.fastcgi.path_info: true +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "fastcgi" + easyhaproxy.plugin.fastcgi.document_root: "/var/www/html" + easyhaproxy.plugin.fastcgi.index_file: "index.php" +spec: + rules: + - host: phpapp.example.com + http: + paths: + - path: / + backend: + service: + name: php-fpm + port: + number: 9000 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: phpapp.local + port: 80 + container: php-fpm:9000 + proto: fcgi + plugins: + - fastcgi + plugin_config: + fastcgi: + document_root: /var/www/html + index_file: index.php + path_info: true +``` + +### Environment Variables + +Configure FastCGI plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|----------------------------------------------|-------------------|----------|-----------------|---------------------------------------| +| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path | +| `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME | +| `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file | +| `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. Custom params (`custom_params`) cannot be configured via environment variables - use YAML or labels instead. + +## Generated HAProxy Configuration + +The plugin generates a top-level `fcgi-app` section and a `use-fcgi-app` directive in the backend: + +```haproxy +# Top-level fcgi-app definition (added after defaults, before frontends/backends) +fcgi-app fcgi_phpapp_local + docroot /var/www/html + index index.php + path-info ^(/.+\.php)(/.*)?$ + +# Backend configuration (added to the backend section) +backend srv_phpapp_local_80 + use-fcgi-app fcgi_phpapp_local + # TCP connection: + server srv-0 172.19.0.3:9000 proto fcgi + # OR Unix socket: + # server srv-0 /run/php/php-fpm.sock proto fcgi +``` + +## CGI Parameters + +**Note:** HAProxy automatically sets standard CGI parameters based on the `fcgi-app` configuration when communicating with PHP-FPM via the FastCGI protocol. + +The plugin configures: +- ✅ **SCRIPT_FILENAME** - Path to PHP script +- ✅ **DOCUMENT_ROOT** - Document root directory +- ✅ **SCRIPT_NAME** - Script name from URL +- ✅ **REQUEST_URI** - Full request URI with query string +- ✅ **QUERY_STRING** - URL query parameters +- ✅ **REQUEST_METHOD** - HTTP method (GET, POST, etc.) +- ✅ **CONTENT_TYPE & CONTENT_LENGTH** - Request body info +- ✅ **SERVER_NAME & SERVER_PORT** - Server details +- ✅ **HTTPS** - SSL/TLS status +- ✅ **PATH_INFO** - Path information (optional) + +## Important Notes + +- **Required:** Use this plugin together with `proto: fcgi` parameter for complete PHP-FPM support +- The plugin runs once per domain during the discovery cycle +- HAProxy handles the actual FastCGI protocol communication and CGI parameter transmission + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/Plugins/ip-whitelist.md b/docs/Plugins/ip-whitelist.md new file mode 100644 index 0000000..2ae8f15 --- /dev/null +++ b/docs/Plugins/ip-whitelist.md @@ -0,0 +1,126 @@ +--- +sidebar_position: 19 +--- + +# IP Whitelist Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The IP Whitelist plugin restricts access to a domain to only specific IP addresses or CIDR ranges. + +## Why Use It + +Restrict access to internal tools, admin panels, or staging environments to only trusted IP addresses. + +## Configuration Options + +| Option | Description | Default | +|---------------|--------------------------------------------------|------------| +| `enabled` | Enable/disable plugin | `true` | +| `allowed_ips` | Comma-separated list of IPs/CIDR ranges to allow | (required) | +| `status_code` | HTTP status code to return for blocked IPs | `403` | + +## Configuration Examples + +### Docker/Docker Compose (Basic) + +```yaml +services: + admin: + labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 192.168.1.0/24,10.0.0.5 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 +``` + +### Office Network Access + +```yaml +labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 203.0.113.0/24,198.51.100.42 +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "ip_whitelist" + easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5" + easyhaproxy.plugin.ip_whitelist.status_code: "403" +spec: + rules: + - host: admin.example.com + http: + paths: + - path: / + backend: + service: + name: admin-panel + port: + number: 80 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: admin.example.com + port: 443 + container: admin-panel:443 + plugins: + - ip_whitelist + plugin_config: + ip_whitelist: + allowed_ips: 192.168.1.0/24,10.0.0.5 + status_code: 403 +``` + +### Environment Variables + +Configure IP Whitelist plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|-----------------------------------------------|---------------|----------|---------|--------------------------------------------------| +| `EASYHAPROXY_PLUGIN_IP_WHITELIST_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_IP_WHITELIST_ALLOWED_IPS` | `allowed_ips` | string | - | Comma-separated list of IPs/CIDR ranges to allow | +| `EASYHAPROXY_PLUGIN_IP_WHITELIST_STATUS_CODE` | `status_code` | integer | `403` | HTTP status code to return for blocked IPs | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. + +## Generated HAProxy Configuration + +```haproxy +# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src 192.168.1.0/24 10.0.0.5 +http-request deny deny_status 403 if !whitelisted_ip +``` + +## IP Address Formats + +The plugin supports: +- **Single IPs:** `10.0.0.5`, `203.0.113.42` +- **CIDR ranges:** `192.168.1.0/24`, `10.0.0.0/8` +- **Multiple entries:** Comma-separated list of IPs and/or CIDR ranges + +## Important Notes + +- **Warning:** This blocks ALL IPs except those in the whitelist. Make sure to include your own IP! +- The plugin runs once per domain during the discovery cycle +- Test thoroughly before deploying to production +- Consider using VPN CIDR ranges for remote access +- Works well with staging and admin environments + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/Plugins/jwt-validator.md b/docs/Plugins/jwt-validator.md new file mode 100644 index 0000000..1b419ea --- /dev/null +++ b/docs/Plugins/jwt-validator.md @@ -0,0 +1,309 @@ +--- +sidebar_position: 16 +--- + +# JWT Validator Plugin + +**Type:** Domain Plugin +**Runs:** Once for each discovered domain/host + +## Overview + +The JWT Validator plugin validates JWT (JSON Web Token) authentication tokens using HAProxy's built-in JWT functionality. + +## Why Use It + +Protect APIs and services with JWT authentication without needing application-level code. + +## Generating JWT Keys + +```bash +# Generate RSA key pair (idempotent - skips if exists) +[ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +[ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +``` + +## Configuration Options + +| Option | Description | Default | +|-------------------|----------------------------------------------------------------------------------------------|-------------| +| `enabled` | Enable/disable plugin | `true` | +| `algorithm` | JWT signing algorithm | `RS256` | +| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) | +| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) | +| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | +| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) | +| `paths` | List of paths that require JWT validation (optional) | (all paths) | +| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` | +| `allow_anonymous` | If `true`, allows requests without Authorization header (validates JWT if present) | `false` | + +## Path Validation Logic + +- **No paths configured:** ALL requests to the domain require JWT validation (default behavior) +- **Paths configured + `only_paths=false`:** Only specified paths require JWT validation, other paths pass through without validation +- **Paths configured + `only_paths=true`:** Only specified paths are accessible (with JWT validation), all other paths are denied + +## Anonymous Access Logic + +- **`allow_anonymous=false` (default):** Requests without `Authorization` header are denied with "Missing Authorization HTTP header" +- **`allow_anonymous=true`:** Requests without `Authorization` header are allowed to pass through, but JWTs are validated if the header is present + +**Use Cases for `allow_anonymous=true`:** +- Optional authentication (show different content for authenticated vs anonymous users) +- Mixed public/private content where some users have enhanced access with JWT +- Gradual JWT authentication rollout +- Public APIs that provide additional features to authenticated users + +## Configuration Examples + +### Docker/Docker Compose (Protect All Paths) + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + volumes: + - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + +### Protect Specific Paths Only + +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: false +# /api/health, /api/docs, etc. remain publicly accessible +``` + +### Only Allow Specific Paths + +```yaml +labels: + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1 + easyhaproxy.http.plugin.jwt_validator.only_paths: true +# All paths except /api/public and /api/v1 are denied +``` + +### Skip Issuer/Audience Validation + +```yaml +labels: + easyhaproxy.http.plugin.jwt_validator.issuer: none + easyhaproxy.http.plugin.jwt_validator.audience: none + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem +``` + +### Allow Anonymous Access (Optional JWT) + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.allow_anonymous: true + volumes: + - ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# Requests without Authorization header are allowed +# Requests with Authorization header are validated +# Invalid JWTs are rejected +``` + +### Kubernetes Annotations + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" + easyhaproxy.plugin.jwt_validator.only_paths: "false" +spec: + rules: + - host: api.example.com + http: + paths: + - path: / + backend: + service: + name: api-service + port: + number: 8080 +``` + +### Static YAML Configuration + +```yaml +# /etc/haproxy/static/config.yaml +easymapping: + - host: api.example.com + port: 443 + container: api-service:8080 + plugins: + - jwt_validator + plugin_config: + jwt_validator: + algorithm: RS256 + issuer: https://auth.example.com/ + audience: https://api.example.com + pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem +``` + +### Environment Variables + +Configure JWT Validator plugin defaults for all domains: + +| Environment Variable | Config Key | Type | Default | Description | +|----------------------------------------------------|-------------------|---------|---------|---------------------------------------------| +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ALGORITHM` | `algorithm` | string | `RS256` | JWT signing algorithm | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ISSUER` | `issuer` | string | - | Expected JWT issuer (optional) | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_AUDIENCE` | `audience` | string | - | Expected JWT audience (optional) | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_PUBKEY_PATH` | `pubkey_path` | string | - | Path to public key file | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_PUBKEY` | `pubkey` | string | - | Public key as base64-encoded string | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_PATHS` | `paths` | string | - | Comma-separated paths requiring JWT | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ONLY_PATHS` | `only_paths` | boolean | `false` | If true, only specified paths accessible | +| `EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ALLOW_ANONYMOUS` | `allow_anonymous` | boolean | `false` | Allow requests without Authorization header | + +**Note:** Environment variables set defaults for ALL domains. To configure per-domain, use container labels or Kubernetes annotations. + +## Generated HAProxy Configuration + +### All Paths Protected + +```haproxy +# JWT Validator - Validate JWT tokens +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } +http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + +### Specific Paths Only (only_paths=false) + +```haproxy +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/admin +acl jwt_protected_path path_beg /api/sensitive + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } if jwt_protected_path + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if jwt_protected_path +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if jwt_protected_path +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if jwt_protected_path + +# Validate JWT (only on protected paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path + +# Validate expiration +http-request set-var(txn.now) date() if jwt_protected_path +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if jwt_protected_path +``` + +### Specific Paths Only (only_paths=true) + +```haproxy +# JWT Validator - Validate JWT tokens + +# Define paths that require JWT validation +acl jwt_protected_path path_beg /api/public +acl jwt_protected_path path_beg /api/v1 + +# Deny access to paths not in the protected list +http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path + +http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + +# Validate JWT (all requests at this point are on allowed paths) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + +# Validate expiration +http-request set-var(txn.now) date() +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +``` + +### Allow Anonymous Access (allow_anonymous=true) + +```haproxy +# JWT Validator - Validate JWT tokens + +# Allow anonymous access - validate JWT only if Authorization header is present + +# Extract JWT header and payload +http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if { req.hdr(authorization) -m found } +http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') if { req.hdr(authorization) -m found } +http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') if { req.hdr(authorization) -m found } +http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') if { req.hdr(authorization) -m found } + +# Validate JWT (only if Authorization header is present) +http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if { req.hdr(authorization) -m found } + +# Validate expiration (only if Authorization header is present) +http-request set-var(txn.now) date() if { req.hdr(authorization) -m found } +http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } if { req.hdr(authorization) -m found } +``` + +## What It Validates + +- ✅ Authorization header presence +- ✅ JWT signing algorithm (RS256, RS512, etc.) +- ✅ JWT issuer (if configured) +- ✅ JWT audience (if configured) +- ✅ JWT signature using public key +- ✅ JWT expiration time + +## Important Notes + +- **Required:** HAProxy 2.5+ with JWT support +- Mount public key file as read-only volume +- The plugin runs once per domain during the discovery cycle +- Test thoroughly with your JWT provider before deploying to production + +## Related Documentation + +- [Plugin System Overview](../plugins.md) +- [Container Labels Reference](../container-labels.md) diff --git a/docs/container-labels.md b/docs/container-labels.md index 9b9bfe2..b791457 100644 --- a/docs/container-labels.md +++ b/docs/container-labels.md @@ -6,20 +6,22 @@ sidebar_position: 11 ## Container (Docker or Swarm) labels -| Label | Description | Default | Example | -|---------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------|------------------------------------------------------------------------------------------------------------------| -| easyhaproxy.[definition].host | Host(s) HAProxy is listening. More than one host use comma as delimiter | **required** | somehost.com OR host1.com,host2.com | -| easyhaproxy.[definition].mode | (Optional) Is this `http` or `tcp` mode in HAProxy. | http | http or tcp | -| easyhaproxy.[definition].port | (Optional) Port HAProxy will listen for the host. | 80 | 3000 | -| easyhaproxy.[definition].localport | (Optional) Port container is listening. | 80 | 8080 | -| easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | \{"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | -| easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `certbot` is enabled. | *empty* | base64 cert + key | -| easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | -| easyhaproxy.[definition].ssl-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | -| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. More info [here](acme.md). | false | true OR false | -| easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | +| Label | Description | Default | Example | +|---------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|--------------|------------------------------------------------------------------------------------------------------------------| +| easyhaproxy.[definition].host | Host(s) HAProxy is listening. More than one host use comma as delimiter | **required** | somehost.com OR host1.com,host2.com | +| easyhaproxy.[definition].mode | (Optional) Is this `http` or `tcp` mode in HAProxy. | http | http or tcp | +| easyhaproxy.[definition].port | (Optional) Port HAProxy will listen for the host. | 80 | 3000 | +| easyhaproxy.[definition].localport | (Optional) Port container is listening. | 80 | 8080 | +| easyhaproxy.[definition].redirect | (Optional) JSON containing key/value pair from host/to URL redirect. | *empty* | \{"foo.com":"https://bla.com", "bar.com":"https://bar.org"} | +| easyhaproxy.[definition].sslcert | (Optional) Cert PEM Base64 encoded. Do not use this if `certbot` is enabled. | *empty* | base64 cert + key | +| easyhaproxy.[definition].ssl | (Optional) If `true` you need to provide certificate as a file. See below. Do not use with `sslcert`. | false | true or false | +| easyhaproxy.[definition].ssl-check | (Optional) `ssl`, enable health check via SSL in `mode tcp` | *empty* | ssl | +| easyhaproxy.[definition].certbot | (Optional) Generate certificate with certbot. Do not use with `sslcert` parameter. More info [here](acme.md). | false | true OR false | +| easyhaproxy.[definition].redirect_ssl | (Optional) Redirect all requests to https | false | true OR false | | easyhaproxy.[definition].clone_to_ssl | (Optional) It copies the configuration to HTTPS(443) and disable SSL from the current config. **Do not use** this with `ssl` or `certbot` parameters | false | true OR false | -| easyhaproxy.[definition].balance | (Optional) HAProxy balance algorithm. See [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4.2-balance) | roundrobin | roundrobin, source, uri, url_param, hdr, rdp-cookie, leastconn, first, static-rr, rdp-cookie, hdr_dom, map-based | +| easyhaproxy.[definition].balance | (Optional) HAProxy balance algorithm. See [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/1.8/configuration.html#4.2-balance) | roundrobin | roundrobin, source, uri, url_param, hdr, rdp-cookie, leastconn, first, static-rr, rdp-cookie, hdr_dom, map-based | +| easyhaproxy.[definition].proto | (Optional) Backend server protocol (e.g., fcgi for PHP-FPM, h2 for HTTP/2) | *empty* | fcgi, h2 | +| easyhaproxy.[definition].socket | (Optional) Unix socket path for backend connection (alternative to host:port) | *empty* | /run/php/php-fpm.sock | :::info Understanding Definitions The `[definition]` is a string identifier that groups related configuration labels together. Different definitions create separate HAProxy configurations. @@ -67,8 +69,6 @@ docker run \ If you are using docker-compose you can use this way: ```yaml -version: "3" - services: mycontainer: image: some/myimage @@ -93,6 +93,50 @@ docker run \ some/tcp-service ``` +### FastCGI (PHP-FPM) Support + +EasyHAProxy supports FastCGI protocol for PHP-FPM and other FastCGI applications. + +#### Using Unix Socket + +```yaml title="PHP-FPM with Unix socket" +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.fcgi.host: phpapp.local + easyhaproxy.fcgi.port: 80 + easyhaproxy.fcgi.socket: /run/php/php-fpm.sock + easyhaproxy.fcgi.proto: fcgi + volumes: + - /run/php:/run/php +``` + +#### Using TCP Connection + +```yaml title="PHP-FPM with TCP connection" +services: + php-fpm: + image: php:8.2-fpm + labels: + easyhaproxy.fcgi.host: phpapp.local + easyhaproxy.fcgi.port: 80 + easyhaproxy.fcgi.localport: 9000 + easyhaproxy.fcgi.proto: fcgi +``` + +**Generated HAProxy Configuration:** + +``` +backend srv_phpapp_local_80 + balance roundrobin + mode http + option forwardfor + http-request set-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto https if { ssl_fc } + server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi +``` + ### Redirect Domains ```bash title="Domain redirect configuration" diff --git a/docs/environment-variable.md b/docs/environment-variable.md index 8b52806..e591616 100644 --- a/docs/environment-variable.md +++ b/docs/environment-variable.md @@ -11,14 +11,18 @@ sidebar_position: 12 | EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* | | EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | | EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 | -| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO | +| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | | CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | -| HAPROXY_USERNAME | (Optional) The HAProxy username to the statistics. | `admin` | -| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics. If not set, statistics will be available with no password | *empty* | -| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics | `1936` | +| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO | +| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` | +| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* | +| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` | | HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | +:::tip HAProxy Stats +Statistics are only configured when `HAPROXY_PASSWORD` is set. Without a password, the stats section is not generated. +::: + :::note ACME/Certbot Environment Variables For ACME/Certbot configuration (Let's Encrypt, ZeroSSL, etc.), see the [ACME documentation](acme.md#environment-variables) for the complete list of `EASYHAPROXY_CERTBOT_*` variables. ::: diff --git a/docs/kubernetes.md b/docs/kubernetes.md index c4b8b67..3fccdec 100644 --- a/docs/kubernetes.md +++ b/docs/kubernetes.md @@ -90,17 +90,152 @@ You don't need to expose any port in your container. ## Kubernetes annotations -| annotation | Description | Default | Example | -|----------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------| -| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | -| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | -| easyhaproxy.certbot | (optional) Boolean. It will request certbot certificates for the ingresses domains. | false | true or false | -| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | \{"domain":"redirect_url"} | -| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | -| easyhaproxy.listen_port | (optional) Override the HTTP listen port created for that ingress | 80 | 8081 | +| annotation | Description | Default | Example | +|-------------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------| +| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | +| easyhaproxy.redirect_ssl | (optional) Boolean. Force redirect all endpoints to HTTPS. | false | true or false | +| easyhaproxy.certbot | (optional) Boolean. It will request certbot certificates for the ingresses domains. | false | true or false | +| easyhaproxy.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | \{"domain":"redirect_url"} | +| easyhaproxy.mode | (optional) Set the HTTP mode for that connection. | http | http or tcp | +| easyhaproxy.listen_port | (optional) Override the HTTP listen port created for that ingress | 80 | 8081 | +| easyhaproxy.plugins | (optional) Comma-separated list of plugins to enable for this ingress | *empty* | cloudflare,deny_pages | +| easyhaproxy.plugin.`{name}`.`{key}` | (optional) Plugin-specific configuration (see [Using Plugins](plugins.md)) | *varies* | See examples below | **Important**: The annotations are per ingress and applied to all hosts in that ingress configuration. +## Using Plugins with Kubernetes + +Plugins extend HAProxy configuration with additional functionality like JWT validation, IP whitelisting, or Cloudflare IP restoration. For a complete list of available plugins, see the [Using Plugins](plugins.md) guide. + +### Enabling Plugins for an Ingress + +Add the `easyhaproxy.plugins` annotation with a comma-separated list of plugin names: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "cloudflare,deny_pages" + name: example-ingress + namespace: example +spec: + rules: + - host: example.org + http: + paths: + - backend: + service: + name: example-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +### Configuring Plugin Options + +Use `easyhaproxy.plugin.{plugin_name}.{option}` annotations to configure individual plugins: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/admin,/private,/config" + easyhaproxy.plugin.deny_pages.status_code: "403" + name: secure-app-ingress + namespace: production +spec: + rules: + - host: myapp.example.com + http: + paths: + - backend: + service: + name: myapp-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +### Common Plugin Examples + +**Protect API with JWT validation:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "jwt_validator" + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" +``` + +**Note:** For JWT validation, you'll need to mount the public key file into the EasyHAProxy pod. See [Using Plugins](plugins.md#protect-api-with-jwt-authentication) for details. + +**Restrict access to specific IPs:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "ip_whitelist" + easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5" + easyhaproxy.plugin.ip_whitelist.status_code: "403" +``` + +**Restore Cloudflare visitor IPs:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "cloudflare" +``` + +**Multiple plugins together:** + +```yaml +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + easyhaproxy.plugins: "cloudflare,deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php" + easyhaproxy.plugin.deny_pages.status_code: "404" +``` + +### Global Plugin Configuration + +Some plugins (like `cleanup`) are global and execute once per discovery cycle. Configure these via environment variables or YAML configuration: + +**Using Helm values.yaml:** + +```yaml +easyhaproxy: + plugins: + enabled: cleanup + config: + cleanup: + max_idle_time: 600 +``` + +**Using environment variables:** + +```yaml +env: + - name: EASYHAPROXY_PLUGINS_ENABLED + value: "cleanup" + - name: EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME + value: "600" +``` + +For more information on plugin types and available plugins, see the [Using Plugins](plugins.md) guide. + ## Certbot / ACME / Letsencrypt It is necessary add the annotation `easyhaproxy.certbot` to the ingress configuration: diff --git a/docs/limitations.md b/docs/limitations.md index 53c2549..9d3bba7 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,5 +1,5 @@ --- -sidebar_position: 15 +sidebar_position: 23 --- # Limitations and Considerations @@ -35,4 +35,4 @@ If you need to run multiple replicas for high availability, **do not activate AC ::: ---- -[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/other.md b/docs/other.md index f376e13..cc45282 100644 --- a/docs/other.md +++ b/docs/other.md @@ -1,5 +1,5 @@ --- -sidebar_position: 14 +sidebar_position: 22 --- # Other configurations @@ -9,7 +9,7 @@ sidebar_position: 14 Some ports on the EasyHAProxy container and in the firewall are required to be open. However, you don't need to expose the other container ports because EasyHAProxy will handle that. - The ports `80` and `443`. -- If you enable the HAProxy statistics, you must also expose the port defined in `HAPROXY_STATS_PORT` environment variable (default 1936). Be aware that statistics are enabled by default with no password. +- If you enable the HAProxy statistics, you must also expose the port defined in `HAPROXY_STATS_PORT` environment variable (default 1936). Statistics are only generated when you set `HAPROXY_PASSWORD`. - Every port defined in `easyhaproxy.[definitions].port` also should be exposed. For example: @@ -51,4 +51,4 @@ If enabled, map the volume : `/etc/haproxy/errors-custom/` to your container and where ERROR_NUMBER is the HTTP error code (e.g., `503.http`) ---- -[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/plugin-development.md b/docs/plugin-development.md new file mode 100644 index 0000000..f017f0c --- /dev/null +++ b/docs/plugin-development.md @@ -0,0 +1,1855 @@ +--- +sidebar_position: 15 +--- + +# Plugin Development Guide + +This comprehensive guide covers everything you need to know about developing plugins for EasyHAProxy. Plugins extend HAProxy configuration with custom functionality and can be integrated seamlessly with Docker, Kubernetes, and Swarm environments. + +## Table of Contents + +1. [Overview](#overview) +2. [Plugin Architecture](#plugin-architecture) +3. [Quick Start Guide](#quick-start-guide) +4. [API Reference](#api-reference) +5. [Advanced Examples](#advanced-examples) +6. [Best Practices](#best-practices) +7. [Testing Guidelines](#testing-guidelines) +8. [Troubleshooting](#troubleshooting) +9. [Distribution](#distribution) + +--- + +## Overview + +### What is a Plugin? + +A plugin is a Python class that implements the `PluginInterface` and extends HAProxy's configuration during the discovery cycle. Plugins can: + +- **Inject HAProxy configuration** - Add custom HAProxy directives (ACLs, http-request rules, etc.) +- **Modify discovery data** - Transform the easymapping structure before HAProxy config generation +- **Perform maintenance tasks** - Execute cleanup, monitoring, or integration tasks +- **Integrate with external services** - Connect to APIs, databases, or third-party systems + +### Why Build a Plugin? + +Build a plugin when you need to: + +- Add domain-specific HAProxy configuration based on labels/annotations +- Integrate with CDNs, load balancers, or security services +- Implement custom authentication or authorization logic +- Perform scheduled maintenance or monitoring tasks +- Extend EasyHAProxy without modifying core code + +### Plugin System Benefits + +- **Zero code changes** - Plugins don't modify EasyHAProxy core +- **Hot reload support** - Plugins reload on each discovery cycle +- **Configuration flexibility** - Configure via YAML, environment variables, or container labels +- **Error isolation** - Plugin errors don't crash the main application (configurable) +- **Easy distribution** - Share plugins as single Python files + +--- + +## Plugin Architecture + +### Plugin Types + +EasyHAProxy supports two plugin execution models: + +#### 1. GLOBAL Plugins + +Execute **once per discovery cycle**, regardless of discovered domains. + +**Execution timing:** After discovery, before domain processing + +**Use cases:** +- Cleanup tasks (removing old temp files) +- Global monitoring (health checks, metrics) +- DNS updates (updating external DNS records) +- Log rotation or archiving +- Integration with global services + +**Example:** CleanupPlugin - removes old temporary files once per cycle + +#### 2. DOMAIN Plugins + +Execute **once per discovered domain/host**. + +**Execution timing:** During domain processing, before backend config generation + +**Use cases:** +- Domain-specific HAProxy rules (IP whitelisting, rate limiting) +- CDN integration (Cloudflare IP restoration) +- Path-based controls (blocking specific URLs) +- Custom headers or redirects per domain +- JWT validation or authentication + +**Example:** CloudflarePlugin - restores visitor IP for each Cloudflare-enabled domain + +### Plugin Lifecycle + +``` +1. LOAD PHASE + ├─ PluginManager scans plugins directory + ├─ Imports plugin modules + ├─ Instantiates plugin classes + └─ Categorizes by type (GLOBAL/DOMAIN) + +2. CONFIGURE PHASE + ├─ Loads configuration from YAML/env + ├─ Calls plugin.configure(config) for each plugin + └─ Validates configuration (plugin responsibility) + +3. EXECUTION PHASE (per discovery cycle) + ├─ GLOBAL PLUGINS + │ └─ Executes all global plugins once + │ + └─ DOMAIN PLUGINS + └─ For each discovered domain: + └─ Executes all domain plugins + +4. RESULT PROCESSING + ├─ Collects PluginResult from each plugin + ├─ Injects haproxy_config into generated config + ├─ Applies modified_easymapping if provided + └─ Logs metadata for debugging +``` + +### Plugin Loading Order + +1. **Builtin plugins** - Loaded from `/src/plugins/builtin/` +2. **External plugins** - Loaded from `/etc/haproxy/plugins/` + +Plugins are discovered automatically by filename (`*.py` excluding `__*.py`). + +### Data Flow + +``` +Container Labels/Annotations + ↓ +Discovery (Docker/K8s/Swarm) + ↓ +parsed_object: {IP: labels} + ↓ +[GLOBAL PLUGINS] ← PluginContext (parsed_object, easymapping, env) + ↓ +easymapping: [list of domain configs] + ↓ +For each domain: + [DOMAIN PLUGINS] ← PluginContext (domain, port, host_config, ...) + ↓ + PluginResult → haproxy_config snippets + ↓ +HAProxy Configuration File + ↓ +HAProxy Reload +``` + +--- + +## Quick Start Guide + +### Step 1: Create Plugin File + +Create a new Python file in `/etc/haproxy/plugins/` (or builtin location for core plugins): + +```python +# /etc/haproxy/plugins/my_plugin.py + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class MyPlugin(PluginInterface): + """My custom plugin description""" + + def __init__(self): + # Initialize default configuration + self.enabled = True + self.my_setting = "default_value" + + @property + def name(self) -> str: + """Return unique plugin name""" + return "my_plugin" + + @property + def plugin_type(self) -> PluginType: + """Return plugin type (GLOBAL or DOMAIN)""" + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure plugin from YAML/env/labels + + Args: + config: Dictionary with plugin configuration + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "my_setting" in config: + self.my_setting = config["my_setting"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Process plugin logic and return result + + Args: + context: PluginContext with execution data + + Returns: + PluginResult with HAProxy config and metadata + """ + if not self.enabled: + return PluginResult() + + # Generate HAProxy configuration + haproxy_config = f"""# My Plugin - Custom functionality +http-request set-header X-My-Header {self.my_setting}""" + + return PluginResult( + haproxy_config=haproxy_config, + metadata={ + "domain": context.domain, + "setting_value": self.my_setting + } + ) +``` + +### Step 2: Enable Plugin + +**Via container label (Docker):** + +```yaml +services: + myapp: + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_setting: custom_value +``` + +**Via YAML configuration:** + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [my_plugin] + config: + my_plugin: + enabled: true + my_setting: custom_value +``` + +**Via environment variable:** + +```bash +EASYHAPROXY_PLUGINS_ENABLED=my_plugin +EASYHAPROXY_PLUGIN_MY_PLUGIN_MY_SETTING=custom_value +``` + +### Step 3: Test Plugin + +Restart EasyHAProxy and check logs: + +```bash +docker-compose restart haproxy +docker-compose logs -f haproxy | grep my_plugin +``` + +Expected output: +``` +[INFO] Loaded external plugin: my_plugin (domain) +[DEBUG] Configured plugin: my_plugin with config: {'my_setting': 'custom_value'} +[DEBUG] Executing domain plugin: my_plugin for domain: example.com +``` + +--- + +## API Reference + +### PluginInterface + +Base class all plugins must inherit from. + +```python +class PluginInterface(ABC): + """Base class all plugins must inherit""" + + @property + @abstractmethod + def name(self) -> str: + """Return the unique plugin name""" + pass + + @property + @abstractmethod + def plugin_type(self) -> PluginType: + """Return the plugin type (GLOBAL or DOMAIN)""" + pass + + @abstractmethod + def configure(self, config: dict) -> None: + """ + Configure the plugin with settings from YAML/env/labels + + Args: + config: Dictionary with plugin-specific configuration + """ + pass + + @abstractmethod + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin logic and return result + + Args: + context: PluginContext with all necessary data + + Returns: + PluginResult with HAProxy config snippets and/or modified data + """ + pass +``` + +**Properties:** + +- `name` - Unique identifier (used in configuration and logs) +- `plugin_type` - Execution model (`PluginType.GLOBAL` or `PluginType.DOMAIN`) + +**Methods:** + +- `configure(config)` - Receives plugin configuration during initialization +- `process(context)` - Main execution logic, returns `PluginResult` + +### PluginType + +Enum defining plugin execution types. + +```python +class PluginType(Enum): + """Plugin execution types""" + GLOBAL = "global" # Execute once per discovery cycle + DOMAIN = "domain" # Execute per domain/host +``` + +### PluginContext + +Container for all plugin execution data. + +```python +@dataclass +class PluginContext: + """Container for all plugin execution data""" + parsed_object: dict # {IP: labels} from discovery + easymapping: list # Current HAProxy mapping structure + container_env: dict # Environment configuration + domain: Optional[str] = None # Domain name (for DOMAIN plugins) + port: Optional[str] = None # Port (for DOMAIN plugins) + host_config: Optional[dict] = None # Domain-specific config +``` + +**Fields:** + +- `parsed_object` - Raw discovery data: `{IP: {label: value, ...}, ...}` +- `easymapping` - Current mapping structure (list of domain configurations) +- `container_env` - Environment variables and global configuration +- `domain` - Domain name (only for DOMAIN plugins) +- `port` - Port number (only for DOMAIN plugins) +- `host_config` - Domain-specific labels/annotations (only for DOMAIN plugins) + +**Usage in GLOBAL plugins:** + +```python +def process(self, context: PluginContext) -> PluginResult: + # Access all discovered services + for ip, labels in context.parsed_object.items(): + print(f"Found service at {ip}: {labels}") + + # Access global environment + debug_mode = context.container_env.get("DEBUG", "false") +``` + +**Usage in DOMAIN plugins:** + +```python +def process(self, context: PluginContext) -> PluginResult: + # Access domain-specific data + domain = context.domain # e.g., "example.com" + port = context.port # e.g., "80" + + # Check domain-specific labels + custom_label = context.host_config.get("custom_label", "default") +``` + +### PluginResult + +Plugin execution result containing configuration and metadata. + +```python +@dataclass +class PluginResult: + """Plugin execution result""" + haproxy_config: str = "" # HAProxy config snippet to inject + modified_easymapping: Optional[list] = None # Modified easymapping structure + metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging +``` + +**Fields:** + +- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend) +- `modified_easymapping` - Modified easymapping structure (optional, advanced use) +- `metadata` - Dictionary with debugging/logging information + +**Examples:** + +```python +# Simple config injection +return PluginResult( + haproxy_config="http-request deny deny_status 403" +) + +# With metadata +return PluginResult( + haproxy_config="acl whitelisted src 10.0.0.0/8", + metadata={ + "domain": context.domain, + "allowed_networks": ["10.0.0.0/8"], + "rules_added": 1 + } +) + +# No operation (plugin disabled or no action needed) +return PluginResult() +``` + +### PluginManager + +Manages plugin loading, configuration, and execution. + +```python +class PluginManager: + """Manages plugin loading, configuration, and execution""" + + def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False): + """ + Initialize the plugin manager + + Args: + plugins_dir: Directory containing plugin files + abort_on_error: If True, abort on plugin errors; if False, log and continue + """ + + def load_plugins(self) -> None: + """Discover and load plugins from the plugins directory""" + + def configure_plugins(self, plugins_config: dict) -> None: + """Configure all loaded plugins with their settings""" + + def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """Execute all global plugins""" + + def execute_domain_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """Execute all domain plugins for a specific domain""" +``` + +**Note:** You typically don't interact with PluginManager directly when writing plugins. It's used by EasyHAProxy core. + +--- + +## Advanced Examples + +### Example 1: IP Whitelist Plugin (DOMAIN) + +Restrict access to specific IP addresses per domain. + +```python +""" +IP Whitelist Plugin for EasyHAProxy + +This plugin restricts access to a domain to only specific IP addresses or CIDR ranges. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - allowed_ips: Comma-separated list of IPs/CIDR ranges to allow + - status_code: HTTP status code to return for blocked IPs (default: 403) + +Example YAML config: + plugins: + ip_whitelist: + enabled: true + allowed_ips: "192.168.1.0/24,10.0.0.1,172.16.0.0/16" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.1" + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 + +HAProxy Config Generated: + # IP Whitelist - Only allow specific IPs + acl whitelisted_ip src 192.168.1.0/24 10.0.0.1 + http-request deny deny_status 403 if !whitelisted_ip +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class IpWhitelistPlugin(PluginInterface): + """Plugin to restrict access to specific IP addresses""" + + def __init__(self): + self.enabled = True + self.allowed_ips = [] + self.status_code = 403 + + @property + def name(self) -> str: + return "ip_whitelist" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - allowed_ips: Comma-separated list of IPs/CIDR ranges + - status_code: HTTP status code to return for denied requests + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.strip()] + + if "status_code" in config: + try: + self.status_code = int(config["status_code"]) + except ValueError: + self.status_code = 403 + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to whitelist specific IPs + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.allowed_ips: + return PluginResult() + + # Create space-separated list of IPs for ACL + ips_str = " ".join(self.allowed_ips) + + # Generate HAProxy config snippet + haproxy_config = f"""# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src {ips_str} +http-request deny deny_status {self.status_code} if !whitelisted_ip""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "allowed_ips": self.allowed_ips, + "status_code": self.status_code + } + ) +``` + +### Example 2: FastCGI Plugin (DOMAIN) + +Configure FastCGI parameters for PHP-FPM and other FastCGI applications. + +```python +""" +FastCGI Plugin for EasyHAProxy + +This plugin generates HAProxy fcgi-app configuration for PHP-FPM and other FastCGI applications. +It runs as a DOMAIN plugin (once per domain). + +The plugin creates: + 1. A top-level fcgi-app section with CGI parameter definitions + 2. A use-fcgi-app directive in the backend + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - document_root: Document root path (default: /var/www/html) + - script_filename: Pattern for SCRIPT_FILENAME (default: %[path]) + - index_file: Default index file (default: index.php) + - path_info: Enable PATH_INFO support (default: true) + - custom_params: Dictionary of custom FastCGI parameters (optional) + +Example YAML config: + plugins: + fastcgi: + enabled: true + document_root: /var/www/html + index_file: index.php + path_info: true + +Example Container Label: + easyhaproxy.http.plugins: "fastcgi" + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: true +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class FastcgiPlugin(PluginInterface): + """Plugin to configure FastCGI parameters for PHP-FPM""" + + def __init__(self): + self.enabled = True + self.document_root = "/var/www/html" + self.script_filename = "%[path]" + self.index_file = "index.php" + self.path_info = True + self.custom_params = {} + + @property + def name(self) -> str: + return "fastcgi" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - document_root: Document root path + - script_filename: Pattern for SCRIPT_FILENAME + - index_file: Default index file + - path_info: Enable PATH_INFO support + - custom_params: Dictionary of custom FastCGI parameters + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "document_root" in config: + self.document_root = config["document_root"] + + if "script_filename" in config: + self.script_filename = config["script_filename"] + + if "index_file" in config: + self.index_file = config["index_file"] + + if "path_info" in config: + self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"] + + if "custom_params" in config: + self.custom_params = config["custom_params"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin and generate FastCGI configuration + + Args: + context: Plugin execution context + + Returns: + PluginResult with HAProxy FastCGI configuration + """ + if not self.enabled: + return PluginResult() + + # Generate a unique fcgi-app name based on the domain + # Replace dots and colons with underscores for valid HAProxy identifier + domain_safe = context.domain.replace(".", "_").replace(":", "_") + fcgi_app_name = f"fcgi_{domain_safe}" + + # Generate the use-fcgi-app directive for the backend + backend_config = f"use-fcgi-app {fcgi_app_name}" + + # Generate the fcgi-app section (to be inserted at top level) + fcgi_app_lines = [f"fcgi-app {fcgi_app_name}"] + fcgi_app_lines.append(f" docroot {self.document_root}") + fcgi_app_lines.append(f" index {self.index_file}") + + # PATH_INFO support + if self.path_info: + fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$") + + # Set SCRIPT_FILENAME if customized + if self.script_filename and self.script_filename != "%[path]": + fcgi_app_lines.append(f" set-param SCRIPT_FILENAME {self.script_filename}") + + # Custom parameters + if self.custom_params: + for param_name, param_value in self.custom_params.items(): + fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}") + + fcgi_app_definition = "\n".join(fcgi_app_lines) + + # Build metadata - store fcgi_app_definition to be extracted and added to global configs + metadata = { + "domain": context.domain, + "fcgi_app_name": fcgi_app_name, + "fcgi_app_definition": fcgi_app_definition, # For top-level injection + "document_root": self.document_root, + "index_file": self.index_file, + "path_info": self.path_info, + "custom_params_count": len(self.custom_params) + } + + return PluginResult( + haproxy_config=backend_config, # use-fcgi-app directive for the backend + modified_easymapping=None, + metadata=metadata + ) +``` + +### Example 3: JWT Validator Plugin (DOMAIN) + +Validate JWT tokens using HAProxy's built-in JWT functionality with path-based validation. + +```python +""" +JWT Validator Plugin for EasyHAProxy + +This plugin validates JWT tokens using HAProxy's built-in JWT functionality. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) + - audience: Expected JWT audience (optional, set to "none"/"null" to skip validation) + - pubkey_path: Path to public key file (required if pubkey not provided) + - pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) + - paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) + - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation + +Path Validation Logic: + - No paths configured: ALL requests to the domain require JWT validation (default behavior) + - Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through + - Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied + +Example YAML config: + plugins: + jwt_validator: + enabled: true + algorithm: RS256 + issuer: https://myaccount.auth0.com/ + audience: https://api.mywebsite.com + pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem + paths: + - /api/admin + - /api/sensitive + only_paths: false + +Example Container Label: + easyhaproxy.http.plugins: "jwt_validator" + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: true +""" + +import base64 +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class JwtValidatorPlugin(PluginInterface): + """Plugin to validate JWT tokens""" + + def __init__(self): + self.enabled = True + self.algorithm = "RS256" + self.issuer = None # Optional + self.audience = None # Optional + self.pubkey_path = None # Path to public key file + self.pubkey = None # Public key content (alternative to pubkey_path) + self.paths = [] # List of paths that require JWT validation + self.only_paths = False # If true, only specified paths are accessible + + @property + def name(self) -> str: + return "jwt_validator" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional) + - audience: Expected JWT audience (optional) + - pubkey_path: Path to public key file + - pubkey: Public key content as base64-encoded string + - paths: List of paths that require JWT validation (optional) + - only_paths: If true, only specified paths are accessible (default: false) + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "algorithm" in config: + self.algorithm = config["algorithm"] + + # Parse issuer (optional - if not set, issuer validation is skipped) + if "issuer" in config: + issuer = str(config["issuer"]).strip() + if issuer: # Only set if not empty + self.issuer = issuer + + # Parse audience (optional - if not set, audience validation is skipped) + if "audience" in config: + audience = str(config["audience"]).strip() + if audience: # Only set if not empty + self.audience = audience + + # Public key configuration + if "pubkey_path" in config: + self.pubkey_path = config["pubkey_path"] + + if "pubkey" in config: + # Decode from base64 (consistent with sslcert parameter) + self.pubkey = base64.b64decode(config["pubkey"]).decode('ascii') + + # Path configuration + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] + + if "only_paths" in config: + self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to validate JWT tokens + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled: + return PluginResult() + + # Determine public key file path + if self.pubkey_path: + pubkey_file = self.pubkey_path + elif self.pubkey: + # Generate path for pubkey based on domain + domain_safe = context.domain.replace(".", "_").replace(":", "_") + pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" + else: + loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") + return PluginResult() + + # Build HAProxy configuration + lines = ["# JWT Validator - Validate JWT tokens"] + + # Determine path condition suffix + path_condition = "" + if self.paths: + # Define ACL for protected paths + lines.append("") + lines.append("# Define paths that require JWT validation") + for path in self.paths: + lines.append(f"acl jwt_protected_path path_beg {path}") + lines.append("") + + if self.only_paths: + # Deny all paths that are not in the protected list + lines.append("# Deny access to paths not in the protected list") + lines.append("http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path") + lines.append("") + # All remaining requests are on protected paths, no condition needed + path_condition = "" + else: + # Only validate JWT on protected paths + path_condition = " if jwt_protected_path" + + # Check for Authorization header + lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") + + # Extract JWT parts + lines.append("") + lines.append("# Extract JWT header and payload") + lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){path_condition}") + lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){path_condition}") + lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){path_condition}") + lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){path_condition}") + + # Validate JWT + lines.append("") + lines.append("# Validate JWT") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{path_condition}") + + # Validate issuer (if configured) + if self.issuer: + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{path_condition}") + + # Validate audience (if configured) + if self.audience: + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{path_condition}") + + # Validate signature + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{path_condition}") + + # Validate expiration + lines.append("") + lines.append("# Validate expiration") + lines.append(f"http-request set-var(txn.now) date(){path_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{path_condition}") + + haproxy_config = "\n".join(lines) + + # Build metadata + metadata = { + "domain": context.domain, + "algorithm": self.algorithm, + "pubkey_file": pubkey_file, + "validates_issuer": self.issuer is not None, + "validates_audience": self.audience is not None, + "path_validation": len(self.paths) > 0, + "only_paths": self.only_paths + } + + if self.issuer: + metadata["issuer"] = self.issuer + if self.audience: + metadata["audience"] = self.audience + if self.pubkey: + metadata["pubkey_content"] = self.pubkey + if self.paths: + metadata["paths"] = self.paths + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata=metadata + ) +``` + +### Example 4: Cleanup Plugin (GLOBAL) + +Perform cleanup tasks during each discovery cycle. + +```python +""" +Cleanup Plugin for EasyHAProxy + +This plugin performs cleanup tasks during each discovery cycle. +It runs as a GLOBAL plugin (once per cycle). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - max_idle_time: Maximum idle time before cleanup in seconds (default: 300) + - cleanup_temp_files: Clean up temporary files (default: true) + +Example YAML config: + plugins: + cleanup: + enabled: true + max_idle_time: 300 + cleanup_temp_files: true + +Example Environment Variable: + EASYHAPROXY_PLUGINS_ENABLED=cleanup + EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +""" + +import os +import sys +import glob +import time + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class CleanupPlugin(PluginInterface): + """Plugin to perform cleanup tasks during discovery cycle""" + + def __init__(self): + self.enabled = True + self.max_idle_time = 300 # 5 minutes + self.cleanup_temp_files = True + + @property + def name(self) -> str: + return "cleanup" + + @property + def plugin_type(self) -> PluginType: + return PluginType.GLOBAL + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - max_idle_time: Maximum idle time in seconds + - cleanup_temp_files: Whether to clean up temp files + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "max_idle_time" in config: + try: + self.max_idle_time = int(config["max_idle_time"]) + except ValueError: + loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") + + if "cleanup_temp_files" in config: + self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Perform cleanup tasks + + Args: + context: Plugin execution context + + Returns: + PluginResult with metadata about cleanup actions + """ + if not self.enabled: + return PluginResult() + + cleanup_actions = [] + + # Cleanup temporary files + if self.cleanup_temp_files: + temp_dirs = ["/tmp", "/var/tmp"] + current_time = time.time() + + for temp_dir in temp_dirs: + if not os.path.exists(temp_dir): + continue + + try: + # Find old EasyHAProxy temp files + pattern = os.path.join(temp_dir, "easyhaproxy_*") + for filepath in glob.glob(pattern): + try: + file_age = current_time - os.path.getmtime(filepath) + if file_age > self.max_idle_time: + os.remove(filepath) + cleanup_actions.append(f"Removed old temp file: {filepath}") + loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") + + # Log cleanup summary + if cleanup_actions: + loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") + + return PluginResult( + haproxy_config="", # No HAProxy config needed for cleanup + modified_easymapping=None, + metadata={ + "actions_performed": len(cleanup_actions), + "actions": cleanup_actions + } + ) +``` + +--- + +## Best Practices + +### 1. Error Handling + +Always handle errors gracefully to avoid breaking HAProxy configuration. + +**Do:** +```python +def configure(self, config: dict) -> None: + if "port" in config: + try: + self.port = int(config["port"]) + except ValueError: + loggerEasyHaproxy.warning(f"Invalid port value: {config['port']}, using default") + self.port = 8080 +``` + +**Don't:** +```python +def configure(self, config: dict) -> None: + self.port = int(config["port"]) # Crashes if not an integer! +``` + +### 2. Configuration Validation + +Validate configuration during `configure()` phase, not during `process()`. + +**Do:** +```python +def configure(self, config: dict) -> None: + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.strip()] + + # Validate IPs + if not self.allowed_ips: + loggerEasyHaproxy.warning("IP whitelist plugin: No valid IPs configured") + self.enabled = False +``` + +**Don't:** +```python +def process(self, context: PluginContext) -> PluginResult: + # Too late - validation should happen during configure() + if not self.allowed_ips: + raise ValueError("No IPs configured") +``` + +### 3. Use Metadata for Debugging + +Include useful debugging information in metadata. + +```python +return PluginResult( + haproxy_config=config_snippet, + metadata={ + "domain": context.domain, + "rules_generated": 5, + "algorithm": self.algorithm, + "validation_enabled": True, + "paths_protected": self.paths + } +) +``` + +### 4. Handle Boolean Configuration + +Support multiple boolean formats (true/false, 1/0, yes/no). + +```python +def configure(self, config: dict) -> None: + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] +``` + +### 5. Support Multiple Configuration Formats + +Support both list and comma-separated string formats for lists. + +```python +def configure(self, config: dict) -> None: + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] +``` + +### 6. Use Descriptive Names + +Use clear, descriptive names for plugins, configuration keys, and ACLs. + +**Do:** +```python +@property +def name(self) -> str: + return "jwt_validator" # Clear and descriptive + +# In generated config: +acl jwt_protected_path path_beg /api +``` + +**Don't:** +```python +@property +def name(self) -> str: + return "jv" # Too cryptic + +# In generated config: +acl p1 path_beg /api # What is p1? +``` + +### 7. Document Your Plugin + +Include comprehensive docstrings with configuration examples. + +```python +""" +Plugin Name for EasyHAProxy + +Brief description of what the plugin does. + +Configuration: + - option1: Description (default: value) + - option2: Description (default: value) + +Example YAML config: + plugins: + plugin_name: + option1: value1 + option2: value2 + +Example Container Label: + easyhaproxy.http.plugins: "plugin_name" + easyhaproxy.http.plugin.plugin_name.option1: value1 +""" +``` + +### 8. Return Empty Result When Disabled + +Always check `enabled` flag and return empty result early. + +```python +def process(self, context: PluginContext) -> PluginResult: + if not self.enabled: + return PluginResult() + + # Plugin logic here... +``` + +### 9. Use Logger Appropriately + +Use appropriate log levels for different messages. + +```python +from functions import loggerEasyHaproxy + +# For debugging +loggerEasyHaproxy.debug(f"Processing domain: {context.domain}") + +# For informational messages +loggerEasyHaproxy.info(f"Loaded plugin configuration: {self.name}") + +# For warnings (non-fatal issues) +loggerEasyHaproxy.warning(f"Invalid configuration value, using default") + +# For errors (fatal issues) +loggerEasyHaproxy.error(f"Failed to load required file: {filepath}") +``` + +### 10. Make Domain-Safe Identifiers + +Replace special characters when generating HAProxy identifiers. + +```python +# Replace dots and colons with underscores for valid HAProxy identifier +domain_safe = context.domain.replace(".", "_").replace(":", "_") +fcgi_app_name = f"fcgi_{domain_safe}" + +# example.com:8080 → fcgi_example_com_8080 +``` + +--- + +## Testing Guidelines + +### Unit Testing + +Create unit tests for your plugin in `/src/tests/test_plugins.py`. + +```python +"""Test cases for MyPlugin""" + +import sys +import os + +# Add src to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginContext +from plugins.builtin.my_plugin import MyPlugin + + +class TestMyPlugin: + """Test cases for MyPlugin (DOMAIN plugin)""" + + def test_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = MyPlugin() + assert plugin.name == "my_plugin" + assert plugin.enabled is True + assert plugin.my_setting == "default_value" + + def test_plugin_configuration(self): + """Test plugin configuration""" + plugin = MyPlugin() + + # Test custom setting + plugin.configure({"my_setting": "custom_value"}) + assert plugin.my_setting == "custom_value" + + # Test disabling + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + # Test enabling with various values + plugin.configure({"enabled": "true"}) + assert plugin.enabled is True + + plugin.configure({"enabled": "1"}) + assert plugin.enabled is True + + def test_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = MyPlugin() + plugin.configure({"my_setting": "test_value"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "My Plugin" in result.haproxy_config + assert "X-My-Header test_value" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["setting_value"] == "test_value" + + def test_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = MyPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} +``` + +### Integration Testing + +Test your plugin in a real environment. + +**Create test fixture:** + +```bash +# Create test service configuration +mkdir -p /home/jg/Projects/opensource/github/byjg/docker-easy-haproxy/src/tests/fixtures/services-my-plugin +``` + +**Create expected output:** + +```bash +# Create expected HAProxy configuration +cat > /home/jg/Projects/opensource/github/byjg/docker-easy-haproxy/src/tests/expected/services-my-plugin.txt << 'EOF' +# Generated HAProxy configuration with my_plugin enabled +backend be_example_com_80 + # My Plugin - Custom functionality + http-request set-header X-My-Header custom_value +EOF +``` + +**Run tests:** + +```bash +cd /home/jg/Projects/opensource/github/byjg/docker-easy-haproxy/src +python -m pytest tests/test_plugins.py::TestMyPlugin -v +``` + +### Manual Testing + +Test your plugin with a live container: + +```yaml +# docker-compose.yml +version: '3.8' + +services: + web: + image: nginx:latest + labels: + easyhaproxy.http.host: test.example.com + easyhaproxy.http.port: 80 + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.my_setting: test_value + + haproxy: + build: . + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./my_plugin.py:/etc/haproxy/plugins/my_plugin.py + environment: + - EASYHAPROXY_DISCOVER=docker +``` + +**Verify plugin loading:** + +```bash +docker-compose up -d +docker-compose logs haproxy | grep my_plugin +``` + +Expected output: +``` +[INFO] Loaded external plugin: my_plugin (domain) +[DEBUG] Configured plugin: my_plugin with config: {'my_setting': 'test_value'} +[DEBUG] Executing domain plugin: my_plugin for domain: test.example.com +``` + +**Verify generated configuration:** + +```bash +docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin" +``` + +--- + +## Troubleshooting + +### Plugin Not Loading + +**Symptom:** Plugin not appearing in logs. + +**Possible causes:** + +1. **File not in plugins directory** + ```bash + ls -la /etc/haproxy/plugins/ + # Ensure my_plugin.py exists + ``` + +2. **Invalid Python syntax** + ```bash + python3 -m py_compile /etc/haproxy/plugins/my_plugin.py + # Check for syntax errors + ``` + +3. **Class doesn't inherit PluginInterface** + ```python + # Wrong: + class MyPlugin: + pass + + # Correct: + class MyPlugin(PluginInterface): + pass + ``` + +4. **Missing required imports** + ```python + # Add this at the top of your plugin: + import os + import sys + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + from plugins import PluginInterface, PluginType, PluginContext, PluginResult + ``` + +### Plugin Not Executing + +**Symptom:** Plugin loads but doesn't execute. + +**Possible causes:** + +1. **Plugin not enabled in configuration** + ```yaml + # Add to config.yaml: + plugins: + enabled: [my_plugin] + ``` + +2. **Wrong plugin type for use case** + - GLOBAL plugins don't receive domain context + - DOMAIN plugins execute per domain, not globally + +3. **Plugin disabled via configuration** + ```python + # Check enabled flag: + if not self.enabled: + return PluginResult() # Plugin is disabled + ``` + +### Configuration Not Applied + +**Symptom:** Plugin executes but configuration not applied. + +**Possible causes:** + +1. **Configuration key mismatch** + ```yaml + # Wrong: + plugins: + config: + my-plugin: # Hyphen instead of underscore + my_setting: value + + # Correct: + plugins: + config: + my_plugin: # Must match plugin.name + my_setting: value + ``` + +2. **Configuration not parsed in configure()** + ```python + def configure(self, config: dict) -> None: + # Make sure to check for your config key: + if "my_setting" in config: + self.my_setting = config["my_setting"] + ``` + +### HAProxy Configuration Invalid + +**Symptom:** HAProxy fails to reload with syntax error. + +**Possible causes:** + +1. **Invalid HAProxy syntax in generated config** + ```bash + # Test configuration manually: + haproxy -c -f /etc/haproxy/haproxy.cfg + ``` + +2. **Missing quotes or escaping** + ```python + # Wrong: + config = f"http-request set-header X-Value {value}" + + # Correct (if value contains spaces): + config = f"http-request set-header X-Value \"{value}\"" + ``` + +3. **Invalid ACL names** + ```python + # Wrong (contains special characters): + acl_name = f"acl_{context.domain}" # example.com → acl_example.com (dot invalid) + + # Correct: + acl_name = f"acl_{context.domain.replace('.', '_')}" # example_com + ``` + +### Plugin Errors + +**Symptom:** Plugin crashes or throws exceptions. + +**Debug steps:** + +1. **Enable debug logging** + ```bash + # Set environment variable: + EASYHAPROXY_LOG_LEVEL=DEBUG + ``` + +2. **Add debug statements** + ```python + def process(self, context: PluginContext) -> PluginResult: + loggerEasyHaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}") + loggerEasyHaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}") + # ... rest of plugin logic + ``` + +3. **Check abort_on_error setting** + ```python + # In PluginManager initialization: + # abort_on_error=False (default) - logs errors and continues + # abort_on_error=True - crashes on errors for debugging + ``` + +4. **Wrap risky operations** + ```python + def process(self, context: PluginContext) -> PluginResult: + try: + # Risky operation + result = self.do_something_risky() + except Exception as e: + loggerEasyHaproxy.error(f"Plugin {self.name} error: {str(e)}") + return PluginResult() # Return empty result on error + ``` + +### Metadata Not Appearing in Logs + +**Symptom:** Plugin metadata not visible in logs. + +**Solution:** + +1. **Enable debug logging** + ```bash + EASYHAPROXY_LOG_LEVEL=DEBUG + ``` + +2. **Ensure metadata is returned** + ```python + return PluginResult( + haproxy_config=config, + metadata={ + "domain": context.domain, + "setting": self.my_setting + } + ) + ``` + +--- + +## Distribution + +### Sharing Your Plugin + +#### Option 1: Single File Distribution + +Share your plugin as a single `.py` file: + +```bash +# Users copy the file to their plugins directory: +cp my_plugin.py /etc/haproxy/plugins/ +``` + +**Advantages:** +- Simple distribution +- No installation required +- Works immediately + +**Best for:** Simple plugins without dependencies + +#### Option 2: GitHub Repository + +Create a GitHub repository with installation instructions: + +``` +my-easyhaproxy-plugin/ +├── README.md +├── my_plugin.py +├── tests/ +│ └── test_my_plugin.py +└── examples/ + ├── docker-compose.yml + └── config.yaml +``` + +**Installation:** +```bash +# Users download and install: +wget https://raw.githubusercontent.com/user/my-plugin/main/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py +``` + +#### Option 3: Docker Image with Plugin + +Create a custom EasyHAProxy image with your plugin included: + +```dockerfile +FROM byjg/easy-haproxy:latest + +# Copy plugin to builtin directory +COPY my_plugin.py /app/src/plugins/builtin/ + +# Optional: Add default configuration +COPY plugin_config.yaml /etc/haproxy/static/config.yaml +``` + +**Build and distribute:** +```bash +docker build -t my-org/easy-haproxy-with-plugin:latest . +docker push my-org/easy-haproxy-with-plugin:latest +``` + +### Documentation + +Include comprehensive documentation with your plugin: + +```markdown +# My Plugin for EasyHAProxy + +Brief description of what your plugin does. + +## Features + +- Feature 1 +- Feature 2 +- Feature 3 + +## Installation + +### Docker +\`\`\`bash +wget https://example.com/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py +\`\`\` + +### Kubernetes +\`\`\`yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: haproxy-plugins +data: + my_plugin.py: | + # Plugin content here +\`\`\` + +## Configuration + +### Options + +- `enabled` (boolean, default: true) - Enable/disable plugin +- `option1` (string, default: "value") - Description + +### Examples + +#### Docker Compose +\`\`\`yaml +services: + web: + labels: + easyhaproxy.http.plugins: my_plugin + easyhaproxy.http.plugin.my_plugin.option1: value +\`\`\` + +#### YAML Config +\`\`\`yaml +plugins: + my_plugin: + enabled: true + option1: value +\`\`\` + +## Troubleshooting + +Common issues and solutions. + +## License + +MIT +``` + +### Version Control + +Use semantic versioning for your plugin: + +```python +class MyPlugin(PluginInterface): + """ + My Plugin for EasyHAProxy + + Version: 1.0.0 + Author: Your Name + License: MIT + """ + + VERSION = "1.0.0" +``` + +### Contributing to EasyHAProxy + +To contribute your plugin to the EasyHAProxy core: + +1. **Fork the repository** + ```bash + git clone https://github.com/byjg/docker-easy-haproxy.git + ``` + +2. **Add your plugin to builtin/** + ```bash + cp my_plugin.py src/plugins/builtin/ + ``` + +3. **Add tests** + ```bash + # Add test class to src/tests/test_plugins.py + ``` + +4. **Update documentation** + ```bash + # Add plugin to docs/plugins.md + ``` + +5. **Create pull request** + - Describe plugin functionality + - Include usage examples + - Show test results + +--- + +## Conclusion + +You now have a comprehensive understanding of the EasyHAProxy plugin system. Key takeaways: + +- **Plugin Types:** GLOBAL (once per cycle) vs DOMAIN (per domain) +- **Plugin Lifecycle:** Load → Configure → Execute → Result +- **API:** PluginInterface, PluginContext, PluginResult +- **Best Practices:** Error handling, validation, logging, testing +- **Distribution:** Single file, GitHub, or Docker image + +For more examples, see the builtin plugins in `/src/plugins/builtin/`: +- `cloudflare.py` - Simple DOMAIN plugin +- `fastcgi.py` - Advanced DOMAIN plugin with complex config +- `jwt_validator.py` - Security plugin with path-based logic +- `ip_whitelist.py` - Access control plugin +- `cleanup.py` - GLOBAL plugin example + +Happy plugin development! diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..9adda9a --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,422 @@ +--- +sidebar_position: 14 +--- + +# Using Plugins + +EasyHAProxy supports a plugin system that extends HAProxy configuration with custom functionality. This guide explains how to use and configure plugins. + +## What are Plugins? + +Plugins automatically run during the discovery cycle and can: +- Add HAProxy configuration directives +- Perform maintenance tasks +- Modify discovery data +- Integrate with external services + +## Plugin Types + +### Global Plugins + +Execute **once per discovery cycle** regardless of how many domains are discovered. + +**Use cases:** +- Cleanup tasks +- Global monitoring +- DNS updates +- Log management + +**Example:** `cleanup` plugin + +### Domain Plugins + +Execute **once for each discovered domain/host**. + +**Use cases:** +- Domain-specific configuration +- IP restoration (Cloudflare) +- Path blocking +- Custom headers per domain + +**Examples:** `cloudflare`, `deny_pages` + +## Built-in Plugins + +EasyHAProxy includes several built-in plugins ready to use: + +- [Cloudflare](Plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN +- [Cleanup](Plugins/cleanup.md) - Cleanup temporary files +- [Deny Pages](Plugins/deny-pages.md) - Block specific paths +- [IP Whitelist](Plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges +- [JWT Validator](Plugins/jwt-validator.md) - Validate JWT tokens +- [FastCGI](Plugins/fastcgi.md) - Configure PHP-FPM and FastCGI applications + +## Configuration Methods + +Plugins can be configured using different methods depending on your deployment environment: + +### 1. Kubernetes Annotations (Ingress Resources) + +Enable and configure domain plugins for specific Kubernetes ingresses: + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Enable plugins + easyhaproxy.plugins: "jwt_validator,deny_pages" + # Configure jwt_validator plugin (protect specific paths only) + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" + easyhaproxy.plugin.jwt_validator.only_paths: "false" + # Configure deny_pages plugin + easyhaproxy.plugin.deny_pages.paths: "/admin,/private" + easyhaproxy.plugin.deny_pages.status_code: "403" + name: api-ingress + namespace: production +spec: + rules: + - host: api.example.com + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +**Annotation format:** +- Enable plugins: `easyhaproxy.plugins: plugin1,plugin2` +- Configure plugin: `easyhaproxy.plugin..: value` + +See the [Kubernetes guide](kubernetes.md#using-plugins-with-kubernetes) for more examples. + +### 2. Container Labels (Docker/Docker Compose) + +Enable and configure domain plugins for specific Docker containers: + +```yaml +services: + webapp: + image: myapp:latest + labels: + easyhaproxy.http.host: example.com + easyhaproxy.http.port: 80 + # Enable multiple plugins + easyhaproxy.http.plugins: cloudflare,deny_pages + # Configure deny_pages plugin + easyhaproxy.http.plugin.deny_pages.paths: /admin,/api/internal + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +**Label format:** +- Enable plugins: `easyhaproxy..plugins: plugin1,plugin2` +- Configure plugin: `easyhaproxy..plugin..: value` + +**Where `` is:** `http`, `https`, `tcp`, etc. + +### 3. Static YAML Configuration + +Configure plugins in `/etc/haproxy/static/config.yaml`: + +```yaml +plugins: + # Global settings + abort_on_error: false # Log and continue on errors (recommended) + + # Enable GLOBAL plugins (run once per discovery cycle) + enabled: [cleanup] + + # Configure plugins (both global and domain plugins) + config: + # Global plugin configuration (cleanup runs once per cycle) + cleanup: + enabled: true + max_idle_time: 600 + + # Domain plugin configuration (applies to ALL domains by default) + cloudflare: + enabled: true # Apply to all domains + use_builtin_ips: true # Use built-in Cloudflare IPs + + # Domain plugin disabled by default (enable per-domain via labels/annotations) + deny_pages: + enabled: false +``` + +**Important distinctions:** + +- **Global plugins** (like `cleanup`): Run once per discovery cycle, configured here only +- **Domain plugins** (like `cloudflare`, `deny_pages`, `jwt_validator`): + - Configuration here sets **defaults for ALL domains** + - Can be enabled/disabled per-domain via container labels or Kubernetes annotations + - Per-domain configuration overrides these defaults + +### 4. Environment Variables + +Configure plugins via environment variables. **Note:** Environment variables set system-wide defaults and cannot configure plugins per-domain. + +```bash +# Enable GLOBAL plugins (run once per discovery cycle) +EASYHAPROXY_PLUGINS_ENABLED=cleanup +EASYHAPROXY_PLUGINS_ABORT_ON_ERROR=false + +# Configure GLOBAL plugins +EASYHAPROXY_PLUGIN_CLEANUP_ENABLED=true +EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 + +# Configure DOMAIN plugins (sets defaults for ALL domains) +EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED=true +EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS=true +``` + +**Variable format:** +- Enable global plugins: `EASYHAPROXY_PLUGINS_ENABLED=plugin1,plugin2` +- Configure plugin: `EASYHAPROXY_PLUGIN__=value` + +**Scope limitations:** +- **Global plugins**: Environment variables configure the single instance +- **Domain plugins**: Environment variables set defaults for ALL domains +- **Per-domain configuration**: Use container labels (Docker) or annotations (Kubernetes) instead + +## Common Use Cases + +### Protect API with JWT Authentication + +**Secure entire API domain:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth0.myapp.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +``` + +**Protect only admin/sensitive endpoints:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/users,/api/billing + easyhaproxy.http.plugin.jwt_validator.only_paths: false + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# /api/health, /api/docs, etc. remain publicly accessible +``` + +**Restrict API to only allow specific endpoints:** + +```yaml +services: + api: + labels: + easyhaproxy.http.host: api.example.com + easyhaproxy.http.plugins: jwt_validator + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/v1,/api/v2 + easyhaproxy.http.plugin.jwt_validator.only_paths: true + volumes: + - ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# All paths except /api/v1 and /api/v2 are denied +``` + +### Restrict Admin Panel to Office IPs + +Protect admin panel by only allowing access from office network: + +```yaml +labels: + easyhaproxy.http.host: admin.example.com + easyhaproxy.http.plugins: ip_whitelist + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 203.0.113.0/24,198.51.100.42 +``` + +### Protect Admin Paths + +Block access to WordPress admin and other sensitive paths: + +```yaml +labels: + easyhaproxy.http.host: wordpress.example.com + easyhaproxy.http.plugins: deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /wp-admin,/wp-login.php,/.env + easyhaproxy.http.plugin.deny_pages.status_code: 404 +``` + +### Cloudflare IP Restoration + +Restore original visitor IPs for applications behind Cloudflare: + +```yaml +labels: + easyhaproxy.http.host: myapp.com + easyhaproxy.http.plugins: cloudflare +``` + +### Multiple Plugins Together + +Combine multiple plugins for one domain: + +```yaml +labels: + easyhaproxy.http.host: secure-app.com + easyhaproxy.http.plugins: cloudflare,deny_pages + easyhaproxy.http.plugin.deny_pages.paths: /admin,/config + easyhaproxy.http.plugin.deny_pages.status_code: 403 +``` + +### Automatic Cleanup + +Keep your system clean with automatic temp file removal: + +```yaml +# /etc/haproxy/static/config.yaml +plugins: + enabled: [cleanup] + config: + cleanup: + enabled: true + max_idle_time: 3600 # 1 hour +``` + +## Error Handling + +### Log and Continue (Recommended) + +By default, plugin errors are logged as warnings and discovery continues: + +```yaml +plugins: + abort_on_error: false # Default +``` + +**When to use:** Most situations. Ensures a failing plugin doesn't prevent HAProxy updates. + +**Behavior:** +- Plugin errors logged as warnings +- Discovery cycle continues +- Other plugins still execute +- HAProxy config is generated without the failed plugin + +### Abort on Error + +Stop discovery cycle if any plugin fails: + +```yaml +plugins: + abort_on_error: true +``` + +**When to use:** Critical plugins where failure should halt deployment. + +**Behavior:** +- Plugin error stops discovery +- Previous HAProxy config remains active +- No configuration changes until issue is resolved + +## Troubleshooting + +### Enable Debug Logging + +See detailed plugin execution information: + +```bash +EASYHAPROXY_LOG_LEVEL=DEBUG +``` + +**Look for:** +``` +INFO: Loaded builtin plugin: cloudflare (domain) +INFO: Loaded builtin plugin: cleanup (global) +DEBUG: Executing domain plugin: cloudflare for domain: example.com +DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/haproxy/cloudflare_ips.lst'} +``` + +### Plugin Not Loading + +**Check:** +1. Plugin file exists in `/etc/haproxy/plugins/` or builtin directory +2. Python syntax is valid +3. Plugin class inherits from `PluginInterface` +4. Check logs for load errors + +### Plugin Not Executing + +**For domain plugins:** +1. Check container has label: `easyhaproxy.http.plugins: plugin_name` +2. Verify plugin name is correct (case-sensitive) +3. Enable debug logging + +**For global plugins:** +1. Check YAML config: `plugins.enabled: [plugin_name]` +2. Or env var: `EASYHAPROXY_PLUGINS_ENABLED=plugin_name` +3. Enable debug logging + +### Configuration Not Applied + +**Check precedence order:** + +For Kubernetes deployments: +1. Ingress annotations (highest) +2. YAML configuration +3. Environment variables (lowest) + +For Docker deployments: +1. Container labels (highest) +2. YAML configuration +3. Environment variables (lowest) + +Per-ingress/per-container settings override global configuration. + +### Plugin Output Missing + +**Verify:** +1. Plugin is enabled (`enabled: true`) +2. Plugin configuration is correct +3. Plugin's `process()` method returns valid `PluginResult` +4. Check debug logs for plugin execution + +## Best Practices + +1. **Start with log-and-continue mode** - Use `abort_on_error: false` until you're confident plugins are stable +2. **Use container labels for domain-specific config** - Easier to manage per-service +3. **Use YAML/env for global config** - Better for global plugins and defaults +4. **Enable debug logging during testing** - Helps identify configuration issues +5. **Test plugin changes in staging first** - Avoid production surprises +6. **Keep plugin configurations simple** - Use defaults when possible + +## Limitations + +- Plugins must be written in Python +- Domain plugins execute for each domain, so keep them lightweight +- Plugins cannot modify the Jinja2 template structure directly +- Plugin errors in abort mode prevent all configuration updates + +## Creating Custom Plugins + +Want to create your own plugins? See the [Plugin Developer Guide](plugin-development.md) for detailed instructions on building custom plugins that extend EasyHAProxy functionality. + +## Further Reading + +- [Plugin Developer Guide](plugin-development.md) - Create custom plugins +- [Container Labels](container-labels.md) - Label configuration reference +- [Environment Variables](environment-variable.md) - Environment variable reference +- [Static Configuration](static.md) - YAML configuration reference +- [Kubernetes Guide](kubernetes.md) - Using plugins with Kubernetes diff --git a/docs/swarm.md b/docs/swarm.md index 348224e..52aeb9e 100644 --- a/docs/swarm.md +++ b/docs/swarm.md @@ -31,8 +31,6 @@ docker network create -d overlay --attachable easyhaproxy And then deploy the EasyHAProxy stack: ```yaml -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 @@ -76,8 +74,6 @@ Mapping to `/var/run/docker.sock` is necessary to discover the docker containers To make your containers "discoverable" by EasyHAProxy, that is the minimum configuration you need: ```yaml -version: "3" - services: container: image: my/image:tag diff --git a/examples/docker/README.md b/examples/docker/README.md new file mode 100644 index 0000000..ecf146f --- /dev/null +++ b/examples/docker/README.md @@ -0,0 +1,52 @@ +# Docker Compose Examples + +Self-contained examples for EasyHAProxy. **All documentation is in the docker-compose files as header comments.** + +## Quick Start + +1. Pick an example below +2. Open the docker-compose file +3. Read the header comments for complete instructions +4. Run the commands step-by-step + +## Basic Examples + +| File | Description | +|----------------------------------------------------------------------------|----------------------------------------------------------------| +| [docker-compose.yml](docker-compose.yml) | Basic SSL setup with two virtual hosts and stats interface | +| [docker-compose-acme.yml](docker-compose-acme.yml) | Let's Encrypt SSL with automatic certificate generation | +| [docker-compose-multi-containers.yml](docker-compose-multi-containers.yml) | Load balancing across multiple container replicas | +| [docker-compose-changed-label.yml](docker-compose-changed-label.yml) | Using custom label prefix (for multiple EasyHAProxy instances) | + +## Real-World Application Examples + +| File | Description | +|--------------------------------------------------------------------------------------|-----------------------------------------------------| +| [docker-compose-portainer.yml](docker-compose-portainer.yml) | Portainer behind EasyHAProxy with Let's Encrypt | +| [docker-compose-portainer-app-example.yml](docker-compose-portainer-app-example.yml) | Additional app alongside Portainer (shared network) | + +## Plugin Examples + +| File | Description | +|----------------------------------------------------------------------------|-----------------------------------------------------| +| [docker-compose-php-fpm.yml](docker-compose-php-fpm.yml) | FastCGI plugin with PHP-FPM and PATH_INFO routing | +| [docker-compose-jwt-validator.yml](docker-compose-jwt-validator.yml) | JWT token validation for API protection | +| [docker-compose-ip-whitelist.yml](docker-compose-ip-whitelist.yml) | IP whitelist for admin panels or sensitive services | +| [docker-compose-cloudflare.yml](docker-compose-cloudflare.yml) | Restore real client IPs when behind Cloudflare CDN | +| [docker-compose-plugins-combined.yml](docker-compose-plugins-combined.yml) | Multiple plugins combined for layered security | + +## Documentation Structure + +Each docker-compose file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Command to launch the stack +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to stop and remove resources + +## Additional Documentation + +- [Container Labels Reference](../../docs/container-labels.md) +- [Docker Configuration Guide](../../docs/docker.md) +- [Environment Variables](../../docs/environment-variable.md) +- [Plugin Documentation](../../docs/plugins/) diff --git a/examples/docker/certs/haproxy/.place_holder_cert.pem b/examples/docker/certs/haproxy/.place_holder_cert.pem deleted file mode 100644 index 49558f4..0000000 --- a/examples/docker/certs/haproxy/.place_holder_cert.pem +++ /dev/null @@ -1,51 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDyTCCArGgAwIBAgIUVpIokbXupa29drpQBmRlKTViM+UwDQYJKoZIhvcNAQEL -BQAwdDELMAkGA1UEBhMCQVUxFTATBgNVBAgMDFBsYWNlIEhvbGRlcjEVMBMGA1UE -BwwMUGxhY2UgSG9sZGVyMSEwHwYDVQQKDBhQbGFjZSBIb2xkZXIgQ2VydGlmaWNh -dGUxFDASBgNVBAMMC2V4YW1wbGUub3JnMB4XDTIyMDgxNTA0NTEwMFoXDTMyMDgx -MjA0NTEwMFowdDELMAkGA1UEBhMCQVUxFTATBgNVBAgMDFBsYWNlIEhvbGRlcjEV -MBMGA1UEBwwMUGxhY2UgSG9sZGVyMSEwHwYDVQQKDBhQbGFjZSBIb2xkZXIgQ2Vy -dGlmaWNhdGUxFDASBgNVBAMMC2V4YW1wbGUub3JnMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAqufw4FdqYcJep7mHHcYGUN79GNBLpvAIdg+1NbKx+cB/ -PtaDuozqVkkT8CmM0Mruay4vCbYkMytCeKgHj2+hLMy7oUQvx2pK/V0i0foPAC0m -gAvgmaWZbQTENHX4A0Rwvim0yixgeBVhz4hTMOIunilSXbKRkFBUidCnYQe1Nzy1 -dbH/fh8++fzLCglDE2kydrE3Zq/54G2xFOxPt1DZRnQ3RBYaMIR/uPPjpVxRWl+p -w4ucklAIZcu2htlOpGl7/3baMtnhpTo9LrkWzSNS7CJQvj6BvDULbcN3+hNPOoVE -MM9MCaM9V+FS25kf+DfyaVUVDVIv0thpwa+f3tCXSwIDAQABo1MwUTAdBgNVHQ4E -FgQUV831A1qfpV+Sy/J+lN6LKLgopsQwHwYDVR0jBBgwFoAUV831A1qfpV+Sy/J+ -lN6LKLgopsQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhnf7 -aW/jpr8JWC0l2lo0z1HpOaSNehhcfNyM6Y43lR3Lr1avibbIXkAOzCuSWanFNnex -dOq8PbG9bmO1ncM6qzXRBzk4pLVJmzzDZs/fPVghSZumY2bzzJFtdCQ5FiLaaE/c -cKtBPvoUjfvrBU9OwFSb9UaoQdxtateb/Kk6JfzWi6YZqxSXFNa0ZTWJaoRFQVj5 -bZZe+wgpGRz46p+2YMwsNolXxa+7yY9x6kOMqZP6++5LZGXm5iWxjWbN4WtqmNnN -cK35fAdLlg4d3wn5tVuTkpKH0FcaRlgpBSjVKejgnFTCxKcOwOCqOfu+nuSSWpa7 -aRqdAbMTeVFQhyLXhQ== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCq5/DgV2phwl6n -uYcdxgZQ3v0Y0Eum8Ah2D7U1srH5wH8+1oO6jOpWSRPwKYzQyu5rLi8JtiQzK0J4 -qAePb6EszLuhRC/Hakr9XSLR+g8ALSaAC+CZpZltBMQ0dfgDRHC+KbTKLGB4FWHP -iFMw4i6eKVJdspGQUFSJ0KdhB7U3PLV1sf9+Hz75/MsKCUMTaTJ2sTdmr/ngbbEU -7E+3UNlGdDdEFhowhH+48+OlXFFaX6nDi5ySUAhly7aG2U6kaXv/dtoy2eGlOj0u -uRbNI1LsIlC+PoG8NQttw3f6E086hUQwz0wJoz1X4VLbmR/4N/JpVRUNUi/S2GnB -r5/e0JdLAgMBAAECggEAI0nY9rmWAbF8ke1A9OjajQA+Ck2YEVQmqxn7NKc9EHCq -1XK9qFtIV6CnOUObC9GbAQ58L+kn+FjKVNd9GCTYhsOPSnEl3GsaKM5+ThTv2/12 -oaHSMmd7EoOVb6+cEjCjhuBdsBERqjngBFYFt2Y8cfPeSfKBE+dCTWKD7QkGZe0Q -tEII2NeE1AoQwG34TANxyn+HbZG809i+k21Pm0tPNyAFwhPvotpjSftMNgco8jcx -39DCt7rG1etpc4VhIUUl8GqsNAHYg8/SfscE18PYeX7RJpKGS128E1mKCqWqn+Ie -VCiMmWoQ4ZBlUFp3tS4+FWcQtDr8Z82QTZQn3ODwIQKBgQDV1NR+SxjBBnzpHDZC -cTxCK0PU37VmPjyr6BAXlftCPLGLgKEVIDRuna6EoiZ0/K0RMDZRoaJgDQPoAXDV -UwJOWYtlJBzUxwQbBJc3S7C3kEC+qeRT9lRwIrn7P6gT1DMcSRsV4ULwGRWAMa2Q -6apbK/K+56Ds7LiStu3OcJSxDwKBgQDMnAvLm0uMo9ZceKG515ruqzQj2YPz2+Zc -f38pD1hESDRj1bzgT09FAsejPnlN7KPp8TFgRUB9Rqb6DZCkx49zdtcDkuZIKfQH -Ga7ITBXnTwe8M+nwq2Q2LJYPdB/p8mBqh4ujA2XIS7ZHCKqOFGsseP4H1uxZeORI -pIPQp4C+BQKBgQDKHw9tAZc4feV8g4pWa6rF8ReBFKTnLFU1OXpckQybo7s/Xirl -STfGh437GTq4wk7lPGlb6CkQGb1jhFkfjANWBBZbWDNYfXZIA6LcRdOY7+YDU5vc -Ma/G/0xFTfqWI7LcPc44dGFNiqhkMJEbtYOuAnDGOzRGP8yIAhnvVUN3yQKBgQC1 -eaYglYGBoQMMi1Xt7iQVkbWyIkedr6l22wJe2aRRE7Wb4sQeM1m8fMWyrUOL8NpF -MU6481NKibKp0AQ9kl5Sa9Iy8kTbNpKhBY93SbyXpwnWTDku4+UDA7KozDdOGVKY -ydX45JeO+lAWWsJjOAsCq+Gr9F020jmvkHL1SsuuPQKBgQDHHCSOlUycLnzxEAks -uiu5MseFzkmdWN1ShrjPkcJ4HLmSD5zRgCySpBB92WPU+yBANlY7WN6fJHVJk/d7 -sNAg78GuAxcWrNAQwu6DRjhnb8zTVICJX8HmDLmsoOHwLsShdckWMks5XEk3NzoV -4ym6aG0tDX+rkBkP/VIjSXA+Cg== ------END PRIVATE KEY----- diff --git a/examples/docker/docker-compose-acme.yml b/examples/docker/docker-compose-acme.yml index 79cbebd..a7bb4c6 100644 --- a/examples/docker/docker-compose-acme.yml +++ b/examples/docker/docker-compose-acme.yml @@ -1,10 +1,62 @@ -# This example shows how to setup HTTP-01 ACME CA Challenge +# ============================================================================== +# EXAMPLE: Let's Encrypt SSL with ACME/Certbot +# ============================================================================== # -# You need -# - public IP pointing your machine -# - open ports 80 and 443 in your firewall - -version: "3" +# WHAT THIS DEMONSTRATES: +# - Automatic SSL certificate generation using Let's Encrypt +# - HTTP-01 ACME challenge protocol +# - Certificate persistence across container restarts +# - Auto-renewal of certificates +# +# REQUIREMENTS (run these first): +# ```bash +# # You MUST have: +# # - A public IP address pointing to your machine +# # - Ports 80 and 443 open in your firewall +# # - A valid domain name with DNS configured +# +# # Edit this file and change: +# # - Line 21: EASYHAPROXY_CERTBOT_EMAIL to your email +# # - Line 36: easyhaproxy.http.host to your real domain +# +# # Create certs directory +# mkdir -p ./certs/certbot +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-acme.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check logs for certificate issuance +# docker compose -f docker-compose-acme.yml logs -f haproxy +# # Look for: "Successfully received certificate" +# +# # Test HTTPS with real domain (replace test.xpto.us with your domain) +# curl https://test.xpto.us/ +# # Expected: 200 OK with valid SSL certificate +# +# # Verify certificate +# openssl s_client -showcerts -connect test.xpto.us:443 < /dev/null | grep "Issuer:" +# # Expected: Issuer: C = US, O = Let's Encrypt +# +# # Check certificate files +# ls -la ./certs/certbot/ +# # Expected: Your domain certificate files +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-acme.yml down +# # Keep certificates: +# # docker compose -f docker-compose-acme.yml down +# # Remove certificates too: +# # docker compose -f docker-compose-acme.yml down && rm -rf ./certs/certbot +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-changed-label.yml b/examples/docker/docker-compose-changed-label.yml index 76ca38c..70af8c3 100644 --- a/examples/docker/docker-compose-changed-label.yml +++ b/examples/docker/docker-compose-changed-label.yml @@ -1,10 +1,40 @@ -# To test: -# curl -k -H "Host: host1.local" https://127.0.0.1/ +# ============================================================================== +# EXAMPLE: Custom Label Prefix +# ============================================================================== # -# or add to /etc/hosts -# 127.0.0.1 host1.local - -version: "3" +# WHAT THIS DEMONSTRATES: +# - Using a custom label prefix instead of default "easyhaproxy" +# - 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 +# docker compose -f docker-compose-changed-label.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test HTTPS +# curl -k -H "Host: host1.local" https://127.0.0.1/ +# # Expected: 200 OK with hostname in response +# +# # Verify custom label prefix is working +# docker inspect $(docker ps -q -f "ancestor=byjg/static-httpserver") | grep "haproxy.http" +# # Expected: Labels starting with "haproxy." instead of "easyhaproxy." +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-changed-label.yml down +# ``` +# +# ============================================================================== services: haproxy: @@ -27,7 +57,7 @@ services: container: image: byjg/static-httpserver labels: - haproxy.http.redirect: host1.local--https://host1.local + haproxy.http.redirect: '{"host1.local": "https://host1.local"}' haproxy.http.host: host1.local haproxy.http.port: 80 diff --git a/examples/docker/docker-compose-cloudflare.yml b/examples/docker/docker-compose-cloudflare.yml new file mode 100644 index 0000000..5325031 --- /dev/null +++ b/examples/docker/docker-compose-cloudflare.yml @@ -0,0 +1,80 @@ +# ============================================================================== +# EXAMPLE: Cloudflare IP Restoration Plugin +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Restoring original visitor IPs when behind Cloudflare CDN +# - Detecting requests from Cloudflare IP ranges +# - Using CF-Connecting-IP header for real client IP +# - Accurate IP logging for applications behind Cloudflare +# +# REQUIREMENTS (run these first): +# ```bash +# # Download Cloudflare IP ranges (idempotent - overwrites if exists) +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# 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: +# ```bash +# docker compose -f docker-compose-cloudflare.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test normal request +# curl -H "Host: myapp.local" http://127.0.0.1/ +# # Expected: 200 OK +# +# # Test with CF-Connecting-IP header (simulating Cloudflare) +# curl -H "Host: myapp.local" -H "CF-Connecting-IP: 203.0.113.50" http://127.0.0.1/ +# # Expected: 200 OK (backend sees 203.0.113.50 as client IP) +# +# # Note: This plugin is most useful when your site is actually behind Cloudflare +# # In production, requests come from Cloudflare IPs and the plugin restores real client IPs +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-cloudflare.yml down +# ``` +# +# ============================================================================== + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Mount Cloudflare IP list + - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # Web application behind Cloudflare + webapp: + image: byjg/static-httpserver + environment: + TITLE: "App Behind Cloudflare" + labels: + easyhaproxy.http.host: myapp.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Enable Cloudflare plugin + easyhaproxy.http.plugins: cloudflare + + # Use custom IP list (disable built-in IPs) + easyhaproxy.http.plugin.cloudflare.use_builtin_ips: false + easyhaproxy.http.plugin.cloudflare.ip_list_path: /etc/haproxy/cloudflare_ips.lst diff --git a/examples/docker/docker-compose-ip-whitelist.yml b/examples/docker/docker-compose-ip-whitelist.yml new file mode 100644 index 0000000..02d7970 --- /dev/null +++ b/examples/docker/docker-compose-ip-whitelist.yml @@ -0,0 +1,81 @@ +# ============================================================================== +# EXAMPLE: IP Whitelist Plugin +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Restricting access to specific IP addresses or CIDR ranges +# - Single IP, CIDR notation, and multiple IP support +# - 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! +# # Default allows localhost and private networks for testing +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-ip-whitelist.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test from localhost (127.0.0.1 is whitelisted) +# curl http://admin.local/ +# # Expected: 200 OK - Access granted +# +# # Test from non-whitelisted IP +# # You'll need to test from another machine or temporarily remove your IP +# # from the allowed_ips list to see the 403 Forbidden response +# +# # View HAProxy stats to see blocked requests +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-ip-whitelist.yml down +# ``` +# +# ============================================================================== + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # Admin panel with IP whitelist + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel - IP Restricted" + labels: + easyhaproxy.http.host: admin.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Enable IP whitelist plugin + easyhaproxy.http.plugins: ip_whitelist + + # Allow localhost and private networks + # UPDATE THIS with your actual IPs/networks! + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 + + # Status code to return for blocked IPs + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 diff --git a/examples/docker/docker-compose-jwt-validator.yml b/examples/docker/docker-compose-jwt-validator.yml new file mode 100644 index 0000000..9eaeff8 --- /dev/null +++ b/examples/docker/docker-compose-jwt-validator.yml @@ -0,0 +1,88 @@ +# ============================================================================== +# EXAMPLE: JWT Validator Plugin +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - JWT token validation for API protection +# - RS256 algorithm signature verification +# - Issuer and audience validation +# - Public key-based JWT verification +# +# REQUIREMENTS (run these first): +# ```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: +# ```bash +# docker compose -f docker-compose-jwt-validator.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test without token (should fail) +# curl http://api.local/ +# # Expected: HTTP 403 - Missing Authorization HTTP header +# +# # Generate test JWT at https://jwt.io with: +# # - Algorithm: RS256 +# # - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# # - Paste contents of jwt_private.pem in private key field +# +# # Test with valid token +# TOKEN="eyJhbGc..." # Replace with your generated token +# curl -H "Authorization: Bearer $TOKEN" http://api.local/ +# # Expected: 200 OK with API response +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-jwt-validator.yml down +# ``` +# +# ============================================================================== + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # Mount the public key for JWT verification + - ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # API service protected by JWT + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API - JWT Required" + labels: + easyhaproxy.http.host: api.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Enable JWT validator plugin + easyhaproxy.http.plugins: jwt_validator + + # JWT validator configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem diff --git a/examples/docker/docker-compose-multi-containers.yml b/examples/docker/docker-compose-multi-containers.yml index 3f07803..aaf8c90 100644 --- a/examples/docker/docker-compose-multi-containers.yml +++ b/examples/docker/docker-compose-multi-containers.yml @@ -1,13 +1,48 @@ -# curl -H Host:www.helloworld.com localhost:19901 -# f6d8d45b7411 -# 59b213cb8592 - -# curl -I -H Host:google.helloworld.com localhost:19901 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: www.google.com/ - -version: "3" +# ============================================================================== +# EXAMPLE: Load Balancing with Multiple Container Replicas +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Multiple container replicas behind a single domain +# - Round-robin load balancing across replicas +# - Domain redirect functionality +# - Custom port configuration +# +# REQUIREMENTS (run these first): +# ```bash +# # No special requirements - this example runs on localhost:19901 +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-multi-containers.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test load balancing - hostname should alternate between containers +# curl -H "Host: www.helloworld.com" localhost:19901 +# # Expected: Container ID (e.g., f6d8d45b7411) +# curl -H "Host: www.helloworld.com" localhost:19901 +# # Expected: Different container ID (e.g., 59b213cb8592) +# +# # Test domain redirect +# curl -I -H "Host: google.helloworld.com" localhost:19901 +# # Expected: HTTP/1.1 301 Moved Permanently, Location: www.google.com/ +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# # You should see 2 backend servers +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-multi-containers.yml down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/docker-compose-php-fpm.yml b/examples/docker/docker-compose-php-fpm.yml new file mode 100644 index 0000000..35355a0 --- /dev/null +++ b/examples/docker/docker-compose-php-fpm.yml @@ -0,0 +1,84 @@ +# ============================================================================== +# EXAMPLE: FastCGI Plugin with PHP-FPM +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - PHP-FPM configuration with FastCGI protocol (proto: fcgi) +# - FastCGI plugin for PHP environment variable configuration +# - TCP connection to PHP-FPM on port 9000 +# - 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 +# docker compose -f docker-compose-php-fpm.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test main page +# curl http://phpapp.local/ +# # Expected: 200 OK with PHP environment info +# +# # Test PHP info page +# curl http://phpapp.local/info.php +# # Expected: phpinfo() output +# +# # Test PATH_INFO routing +# curl http://phpapp.local/test-path-info.php/users/123 +# # Expected: PATH_INFO=/users/123 +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-php-fpm.yml down +# ``` +# +# ============================================================================== + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # PHP-FPM service using byjg/php image + php-fpm: + image: byjg/php:8.5-fpm + volumes: + # Mount PHP application files + - ./php-app:/var/www/html:ro + labels: + easyhaproxy.http.host: phpapp.local + easyhaproxy.http.port: 80 + # PHP-FPM listens on port 9000 + easyhaproxy.http.localport: 9000 + easyhaproxy.http.proto: fcgi + + # Enable FastCGI plugin for PHP environment configuration + easyhaproxy.http.plugins: fastcgi + + # FastCGI plugin configuration + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: "true" diff --git a/examples/docker/docker-compose-plugins-combined.yml b/examples/docker/docker-compose-plugins-combined.yml new file mode 100644 index 0000000..68bcd6b --- /dev/null +++ b/examples/docker/docker-compose-plugins-combined.yml @@ -0,0 +1,138 @@ +# ============================================================================== +# EXAMPLE: Multiple Plugins Combined +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Using multiple security plugins together +# - Different plugin combinations for different services +# - Cloudflare + path blocking for public sites +# - JWT validation + path blocking for APIs +# - IP whitelist for admin panels +# - Layered security approach +# +# REQUIREMENTS (run these first): +# ```bash +# # Generate SSL certificates and JWT keys (from project root) +# cd ../.. && ./examples/generate-keys.sh && cd examples/docker +# +# # Download Cloudflare IPs (idempotent - overwrites if exists) +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# 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 +# docker compose -f docker-compose-plugins-combined.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test public website (Cloudflare + path blocking) +# curl http://website.local/ +# # Expected: 200 OK +# curl http://website.local/admin +# # Expected: HTTP 404 - Path blocked +# +# # Test protected API (JWT required) +# curl http://api.local/ +# # 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/ +# # Expected: 200 OK +# +# # Test admin panel (IP whitelist) +# curl http://admin.local/ +# # Expected: 200 OK from localhost +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# # You should see 3 backends with different security configurations +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-plugins-combined.yml down +# ``` +# +# ============================================================================== + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./cloudflare_ips.lst:/etc/haproxy/cloudflare_ips.lst:ro + - ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro + environment: + EASYHAPROXY_DISCOVER: docker + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + + # Public website with Cloudflare + path blocking + website: + image: byjg/static-httpserver + environment: + TITLE: "Public Website" + labels: + easyhaproxy.http.host: website.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # Combine Cloudflare IP restoration + deny pages + easyhaproxy.http.plugins: cloudflare,deny_pages + + # Block admin paths, config files, etc. + easyhaproxy.http.plugin.deny_pages.paths: /admin,/wp-admin,/wp-login.php,/.env,/config + easyhaproxy.http.plugin.deny_pages.status_code: 404 + + # Protected API with JWT validation + path blocking + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API" + labels: + easyhaproxy.http.host: api.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # JWT validation + block internal endpoints + easyhaproxy.http.plugins: jwt_validator,deny_pages + + # JWT configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + + # Block internal/debug endpoints + easyhaproxy.http.plugin.deny_pages.paths: /internal,/debug,/metrics + easyhaproxy.http.plugin.deny_pages.status_code: 403 + + # Admin panel with strict IP restrictions + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel" + labels: + easyhaproxy.http.host: admin.local + easyhaproxy.http.port: 80 + easyhaproxy.http.localport: 8080 + + # IP whitelist only (strictest security) + easyhaproxy.http.plugins: ip_whitelist + + # Only allow local and private networks (including Docker bridge) + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: 127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 diff --git a/examples/docker/docker-compose-portainer-app-example.yml b/examples/docker/docker-compose-portainer-app-example.yml index 99c9ed1..ffbd963 100644 --- a/examples/docker/docker-compose-portainer-app-example.yml +++ b/examples/docker/docker-compose-portainer-app-example.yml @@ -1,4 +1,45 @@ -version: "3" +# ============================================================================== +# EXAMPLE: Additional Application with Portainer +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Adding more applications to an existing EasyHAProxy setup +# - Using the shared "easyhaproxy" network +# - Multiple applications behind the same HAProxy instance +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. First start the Portainer stack (creates network and HAProxy) +# docker compose -f docker-compose-portainer.yml up -d +# +# # 2. Edit this file and change: +# # - Line 6: easyhaproxy.http.host to your real domain +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-portainer-app-example.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check container is running +# docker compose -f docker-compose-portainer-app-example.yml ps +# +# # Test the application +# curl http://test.xpto.us +# # OR with /etc/hosts: echo "127.0.0.1 test.xpto.us" | sudo tee -a /etc/hosts +# +# # Verify both apps in HAProxy stats (port 1936) +# # You should see backends for both portainer.xpto.us and test.xpto.us +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-portainer-app-example.yml down +# ``` +# +# ============================================================================== services: container: diff --git a/examples/docker/docker-compose-portainer.yml b/examples/docker/docker-compose-portainer.yml index 140e656..39c4917 100644 --- a/examples/docker/docker-compose-portainer.yml +++ b/examples/docker/docker-compose-portainer.yml @@ -1,11 +1,62 @@ +# ============================================================================== +# EXAMPLE: Portainer Behind EasyHAProxy +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Running Portainer (Docker management UI) behind EasyHAProxy +# - Using external volumes and networks for shared infrastructure +# - Real-world application example with Let's Encrypt +# - HTTP to HTTPS redirect with Certbot +# +# REQUIREMENTS (run these first): +# ```bash +# # Create required volumes (idempotent) # docker volume create certs_certbot # docker volume create certs_haproxy # docker volume create portainer_data +# +# # Create shared network (idempotent) # docker network create easyhaproxy +# +# # Edit this file and change: +# # - Line 18: EASYHAPROXY_CERTBOT_EMAIL to your email +# # - Line 38: easyhaproxy.http.host to your real domain +# ``` +# +# HOW TO START: +# ```bash +# docker compose -f docker-compose-portainer.yml up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check containers are running +# docker compose -f docker-compose-portainer.yml ps +# # Expected: Both easyhaproxy and portainer containers running +# +# # Access Portainer (replace with your domain or use /etc/hosts) +# # First time: Create admin user +# curl http://portainer.xpto.us +# # OR with /etc/hosts: echo "127.0.0.1 portainer.xpto.us" | sudo tee -a /etc/hosts +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose -f docker-compose-portainer.yml down +# # To also remove volumes: +# # docker compose -f docker-compose-portainer.yml down -v +# # docker volume rm certs_certbot certs_haproxy portainer_data +# # docker network rm easyhaproxy +# ``` +# +# ============================================================================== -version: "3" - services: easyhaproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/docker/docker-compose.yml b/examples/docker/docker-compose.yml index 8894a4b..db59b38 100644 --- a/examples/docker/docker-compose.yml +++ b/examples/docker/docker-compose.yml @@ -1,21 +1,53 @@ -# To test: +# ============================================================================== +# EXAMPLE: Basic SSL Setup with Two Virtual Hosts +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Basic SSL setup with custom certificates (embedded base64 and file-based) +# - Automatic HTTP to HTTPS redirect +# - Two virtual hosts (host1.local and host2.local) +# - 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 +# ``` +# +# HOW TO START: +# ```bash +# docker compose up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test HTTPS # curl -k -H "Host: host1.local" https://127.0.0.1/ # curl -k -H "Host: host2.local" https://127.0.0.1/ -# -# curl -I -H Host:host1.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Expected: 200 OK with hostname in response # -# curl -I -H Host:host2.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Test HTTP redirect +# curl -I -H "Host: host1.local" http://127.0.0.1 +# # Expected: HTTP/1.1 301 Moved Permanently, Location: https://host1.local/ # -# Test SSL: -# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local - -version: "3" +# # View SSL certificate +# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local < /dev/null +# +# # Access stats interface +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose down +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/docker/host2.local.pem b/examples/docker/host2.local.pem deleted file mode 100644 index 917062a..0000000 --- a/examples/docker/host2.local.pem +++ /dev/null @@ -1,50 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDqTCCApGgAwIBAgIUId5b9t9uqH78g02EzbWF6FKVw3gwDQYJKoZIhvcNAQEL -BQAwZDELMAkGA1UEBhMCQlIxFzAVBgNVBAgMDlJpbyBkZSBKYW5laXJvMRcwFQYD -VQQHDA5SaW8gZGUgSmFuZWlybzENMAsGA1UECgwEQUNNRTEUMBIGA1UEAwwLaG9z -dDIubG9jYWwwHhcNMjIwODE1MDQyNzA1WhcNMjMwODE1MDQyNzA1WjBkMQswCQYD -VQQGEwJCUjEXMBUGA1UECAwOUmlvIGRlIEphbmVpcm8xFzAVBgNVBAcMDlJpbyBk -ZSBKYW5laXJvMQ0wCwYDVQQKDARBQ01FMRQwEgYDVQQDDAtob3N0Mi5sb2NhbDCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMSKvrOahavCXnvSF5131hpo -6k65C57jgRQ84FaDj5MbJOVlYQVFtMG0XOk7a+hh5v1fe4wH0R7I6FDo0V9sS+ss -ko5bsElc1xYlg5HbuKq89vRSKg6EDlztx3BKbi912Pmt5vFGNJ16zcw77DUrQIXo -4I/b4a3pmBiWj43NoTIrmSWHtsGwwOj3iDvSweqdYXJIr3hpHH5u6pohjDoQvqDz -K6Mu8p6mhCUKNs7KFJnNInNG25oQT6O0n4OGtmgRjLWopdEnOhMkKsfIoI1XtlXB -LBDv7huICk3t5ywtfCQyO09kX7lFIgd5rn7+MjwH5WNeqbQJxuaqjoXQnNZUgUsC -AwEAAaNTMFEwHQYDVR0OBBYEFNhMBG8q6a+iK2nECwVTn6B9EXZOMB8GA1UdIwQY -MBaAFNhMBG8q6a+iK2nECwVTn6B9EXZOMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI -hvcNAQELBQADggEBAJmudvx8+p5iIUsT8fm/fbVM0DA6qWALDYUJnTn3j6Lq4vpf -PFC+q1LmuWfBQMyqKrHrP3e493EctXoiSKZO6iN5dVJIur02OjGuiAEcsYuY1nLn -s9piiI+UEwxH6ux1NaHUnzsWauoBvRhzjXvO6SAVSZJYa9dY5mizXklDyDNuG5U0 -lXv9egMGBsy0dG6eFXkU5CPdxWU540yI2sCtSAj7z+WRUD5k7gJ7tVoY3//jHQZG -5STTmm5t9kpIZTWkptyJos9oZJFYMIXqW2Fc6tyLZpRp31R78tDs6ETIkToDc0RR -jz66th6HI+ZlgIBQhw09+hYAhBDe9+Dmd/SzQZc= ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEir6zmoWrwl57 -0hedd9YaaOpOuQue44EUPOBWg4+TGyTlZWEFRbTBtFzpO2voYeb9X3uMB9EeyOhQ -6NFfbEvrLJKOW7BJXNcWJYOR27iqvPb0UioOhA5c7cdwSm4vddj5rebxRjSdes3M -O+w1K0CF6OCP2+Gt6ZgYlo+NzaEyK5klh7bBsMDo94g70sHqnWFySK94aRx+buqa -IYw6EL6g8yujLvKepoQlCjbOyhSZzSJzRtuaEE+jtJ+DhrZoEYy1qKXRJzoTJCrH -yKCNV7ZVwSwQ7+4biApN7ecsLXwkMjtPZF+5RSIHea5+/jI8B+VjXqm0Ccbmqo6F -0JzWVIFLAgMBAAECggEBAKceitVROQQ5e/mxRR9CfK1sNH/H3Ne3/1PkB6XIrFab -qB3evEatZOuon7A6NKEeTjl37Se+pdSVZOUXcqC/BzbraZre3+EhrkpIj72ApV+Y -2iwZiWVaaJQgI4uZ3mNAw8RaWJsj5S1a9I8LDOiQ5IZ45CmvABDPJeMScvJSvRRY -e5N0L6stqS7Z+IoyGVKUfp1iNO0YyywOUiSkIRXgscuRXZGYpiGPomsJ+Js1ejzW -jyStlZJEr4L1285rGPrmHqjTwFd+hG80Wc4179xL+WRE6HBEUZSiy95fe6kcPHXX -BgiVYtcFKmiBi2dTbxl4e94ut239i0HtlJ1ZJtLh+BECgYEA8a298K2zXkHosxhN -tRrH7XfMPTkHDDd3rxM21LT+fIXqinGUp9LYaDcbjuTPs8e33uKMd7R6Q40x89yW -IXNka/VL0PXUeV67aCVLLqgXDLGudluJinH0XvmI0CmBecFSMqIFmQlgqERoGGs3 -UMac0p876T4XkGQQJdf62bFpE4UCgYEA0DBH0PDlOpwXccDgXrMfayr8HAhI+G5R -yWQ//9iirtU83chwIWwkh53eLLMzLgdqJnPiWyaUW5BqzmYuD23nhxcQ7PNdIqOO -H1sE6zqLNshv46t5QKlh1Q4qjd7UqtgrSrY63RXJCMWTwnNMeDLtj8gaKbjkrG3R -BM2ildt6Uo8CgYBX7NDUli1SloH1XlsvD047S8FHaM7yl994F3J0UmDfpszcj1P4 -9pF64Mmq4/3Yt0li0mMuTb/Jgb3xrYgFJXkcecKahEVH3ropup+umsLAAIirUMQq -VSkFwJ0Qtnj/deDUwPNuaOX8cd65O5CFV6zIR9xBEDD8fBsP2ZLOzmefDQKBgQDF -m24vVthd/1cJdCgD+0VxNYXDHeIVXLFo1S0iLYCNLn3tjZlRQBKUXzZJe3ay0/rf -sNND7aSYHMYkTzydDJbc1PoNzxmyDUiTXpOWqyUExM/fbB1VUPE5h47AxqdZ2oGN -EtdgjpMZLmCIC2SkGsL+3NJok8UKHdpuErmmQIMk5QKBgQCgEWcYtLXC3YDYMFdI -UgcTebFqSs3mLYgub1xekW3IXR2yom4V5fQTLiF7Yfn2dpDW4IcMU0UJFYVsUlhK -aGtet4Vm5Nn8+Mghot5yAjqO9yAUaub7wgifKIe99tQKd8uZyCvJ0hhvmDDSfx4m -B/TEiFAO99yF49iSxEVSAS6pqQ== ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/docker/php-app/README.md b/examples/docker/php-app/README.md new file mode 100644 index 0000000..f869c88 --- /dev/null +++ b/examples/docker/php-app/README.md @@ -0,0 +1,151 @@ +# Sample PHP Application for FastCGI Plugin + +This directory contains a sample PHP application that demonstrates the FastCGI plugin functionality with EasyHAProxy. + +## Files + +### index.php +The main page that displays: +- PHP version and configuration +- FastCGI environment variables set by EasyHAProxy +- How the FastCGI plugin works +- Links to test pages + +Access: `http://phpapp.local/` + +### info.php +Standard `phpinfo()` page showing complete PHP configuration. + +Access: `http://phpapp.local/info.php` + +### test-path-info.php +Demonstrates PATH_INFO support for RESTful URL routing. + +Examples: +- `http://phpapp.local/test-path-info.php/users` +- `http://phpapp.local/test-path-info.php/users/123` +- `http://phpapp.local/test-path-info.php/api/v1/products` + +## FastCGI Environment Variables + +The FastCGI plugin generates an `fcgi-app` configuration that defines these CGI parameters for HAProxy to use: + +| Variable | Description | Example | +|----------|-------------|---------| +| `SCRIPT_FILENAME` | Full path to PHP script | `/var/www/html/index.php` | +| `DOCUMENT_ROOT` | Document root directory | `/var/www/html` | +| `SCRIPT_NAME` | Script path | `/index.php` | +| `REQUEST_URI` | Full request URI with query | `/index.php?page=1` | +| `QUERY_STRING` | Query string parameters | `page=1&limit=10` | +| `REQUEST_METHOD` | HTTP method | `GET`, `POST`, etc. | +| `CONTENT_TYPE` | Request content type | `application/json` | +| `CONTENT_LENGTH` | Request body length | `1024` | +| `SERVER_NAME` | Virtual host name | `phpapp.local` | +| `SERVER_PORT` | Server port | `80` or `443` | +| `HTTPS` | SSL status | `on` or `off` | +| `PATH_INFO` | Extra path info (optional) | `/users/123` | + +## How It Works + +1. **FastCGI plugin generates configuration** (at startup) + - Creates an `fcgi-app` section with CGI parameter definitions + - Includes `docroot`, `index`, and `path-info` settings + - Adds `use-fcgi-app` directive to the backend + +2. **Request arrives at HAProxy** (port 80) + - URL: `http://phpapp.local/index.php` + +3. **HAProxy uses the fcgi-app configuration** + - Sets `SCRIPT_FILENAME` to `/var/www/html/index.php` + - Sets `DOCUMENT_ROOT` to `/var/www/html` + - Sets all other CGI variables based on the request + - Handles directory requests (appends `index.php`) + +4. **HAProxy forwards to PHP-FPM** via FastCGI protocol + - Host: `php-fpm` (container name) + - Port: `9000` (TCP) or Unix socket + - Protocol: `fcgi` + - Sends CGI parameters in FastCGI format + +5. **PHP-FPM executes the script** + - Reads the PHP file from `SCRIPT_FILENAME` + - Processes the PHP code with CGI environment + - Returns HTML/JSON response + +6. **HAProxy sends response to client** + +## Customizing + +You can customize the FastCGI plugin configuration in `docker-compose-php-fpm.yml`: + +```yaml +labels: + # Change document root + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/public + + # Change default index file + easyhaproxy.http.plugin.fastcgi.index_file: app.php + + # Disable PATH_INFO + easyhaproxy.http.plugin.fastcgi.path_info: "false" + + # Add custom FastCGI parameters + easyhaproxy.http.plugin.fastcgi.custom_params: '{"PHP_VALUE":"memory_limit=256M","APP_ENV":"production"}' +``` + +## Adding Your Own PHP Application + +Replace the contents of this directory with your own PHP application: + +```bash +# Remove sample files +rm -rf php-app/* + +# Copy your PHP application +cp -r /path/to/your/app/* php-app/ + +# Restart the stack +docker compose -f docker-compose-php-fpm.yml restart +``` + +Make sure your application's entry point matches the `index_file` configuration (default: `index.php`). + +## Troubleshooting + +### "File not found" error + +Check that: +1. The file exists in the `php-app/` directory +2. The `document_root` matches the container path (`/var/www/html`) +3. The volume mount is correct in docker-compose.yml + +### PATH_INFO not working + +Ensure `path_info` is enabled in the plugin configuration: +```yaml +easyhaproxy.http.plugin.fastcgi.path_info: "true" +``` + +### PHP-FPM connection error + +Verify: +1. The `localport: 9000` is set correctly +2. The `proto: fcgi` parameter is set +3. Both containers are running and can communicate + +View logs: +```bash +docker compose -f docker-compose-php-fpm.yml logs php-fpm +docker compose -f docker-compose-php-fpm.yml logs haproxy +``` + +Check connectivity: +```bash +docker compose -f docker-compose-php-fpm.yml exec haproxy ping php-fpm +``` + +## Learn More + +- [FastCGI Plugin Documentation](../../../docs/plugins.md#fastcgi-plugin) +- [Container Labels Reference](../../../docs/container-labels.md) +- [HAProxy FastCGI Documentation](https://docs.haproxy.org/2.8/configuration.html#5.2-proto) diff --git a/examples/docker/php-app/index.php b/examples/docker/php-app/index.php new file mode 100644 index 0000000..34a20f0 --- /dev/null +++ b/examples/docker/php-app/index.php @@ -0,0 +1,152 @@ + + + + + + PHP-FPM with EasyHAProxy + + + +
+

PHP-FPM with EasyHAProxy FastCGI Plugin

+ +
+ Success! PHP is running via FastCGI protocol through HAProxy. +
+ +

FastCGI Environment

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PHP Version
Server Software
Document Root
Script Filename
Request URI
Request Method
Server Name
Server Port
HTTPS
PATH_INFO
Gateway Interface
+ +

Test Links

+ + +

How This Works

+

+ This setup uses HAProxy with EasyHAProxy to proxy requests to PHP-FPM via the FastCGI protocol: +

+
    +
  1. HAProxy receives HTTP request on port 80
  2. +
  3. The FastCGI plugin generates an fcgi-app configuration that defines CGI parameters (SCRIPT_FILENAME, DOCUMENT_ROOT, etc.)
  4. +
  5. HAProxy uses this configuration to communicate with PHP-FPM via the FastCGI protocol
  6. +
  7. HAProxy connects to PHP-FPM (via TCP port 9000 or Unix socket, depending on configuration)
  8. +
  9. PHP-FPM processes the PHP script and returns the response
  10. +
  11. HAProxy sends the response back to the client
  12. +
+ +

Configuration

+

The FastCGI plugin is configured in docker-compose-php-fpm.yml:

+
    +
  • document_root: /var/www/html
  • +
  • index_file: index.php
  • +
  • path_info: true (enables PATH_INFO support)
  • +
+
+ + diff --git a/examples/docker/php-app/info.php b/examples/docker/php-app/info.php new file mode 100644 index 0000000..9a6e273 --- /dev/null +++ b/examples/docker/php-app/info.php @@ -0,0 +1,9 @@ + + + + + + PATH_INFO Test + + + +
+

PATH_INFO Test

+ + +
+ Success! PATH_INFO is working correctly. +
+ +

PATH_INFO Value

+
+ +

Parsed Path Segments

+
+ + +
+ Note: PATH_INFO is not set. Try accessing this page with additional path segments. +
+ + +

Request Information

+
+ +

Example Usage

+

PATH_INFO enables RESTful URL routing. Try these URLs:

+ + +

← Back to Home

+
+ + diff --git a/examples/generate-keys.sh b/examples/generate-keys.sh new file mode 100755 index 0000000..96d0cf6 --- /dev/null +++ b/examples/generate-keys.sh @@ -0,0 +1,97 @@ +#!/bin/bash +set -e + +# Generate SSL Certificates and JWT Keys for EasyHAProxy Examples +# This script creates all .pem files needed for the examples directory + +echo "Generating SSL certificates and JWT keys for EasyHAProxy examples..." +echo "" + +# Create necessary directories +mkdir -p examples/static +mkdir -p examples/docker +mkdir -p examples/docker/certs/haproxy +mkdir -p examples/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 \ + -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" +echo "" + +# ============================================================================ +# Generate SSL Certificate for host2.local (2048-bit RSA, 1-year validity) +# ============================================================================ +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 \ + -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" +echo "" + +# ============================================================================ +# Generate JWT RSA Key Pair (2048-bit) +# ============================================================================ +echo "Generating JWT RSA key pair (2048-bit)..." + +# Generate private key +openssl genrsa -out examples/docker/jwt_private.pem 2048 + +# Extract public key +openssl rsa -in examples/docker/jwt_private.pem -pubout -out examples/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 "" + +# ============================================================================ +# Generate Placeholder Certificate +# ============================================================================ +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 \ + -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 "" + +# ============================================================================ +# Summary +# ============================================================================ +echo "============================================" +echo "All certificates and keys generated successfully!" +echo "============================================" +echo "" +echo "SSL Certificates:" +echo " - host1.local (4096-bit, 10 years)" +echo " - host2.local (2048-bit, 1 year)" +echo "" +echo "JWT Keys:" +echo " - jwt_private.pem (private key for signing)" +echo " - jwt_pubkey.pem (public key for validation)" +echo "" +echo "IMPORTANT NOTES:" +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 "" diff --git a/examples/kubernetes/README.md b/examples/kubernetes/README.md new file mode 100644 index 0000000..2ab7e02 --- /dev/null +++ b/examples/kubernetes/README.md @@ -0,0 +1,50 @@ +# Kubernetes Examples + +Self-contained examples for EasyHAProxy ingress controller. **All documentation is in the YAML files as header comments.** + +## Quick Start + +1. Pick an example below +2. Open the YAML file +3. Read the header comments for complete instructions +4. Run the commands step-by-step + +## Prerequisites + +All examples require: +- EasyHAProxy installed in your Kubernetes cluster +- Node labeled for EasyHAProxy deployment + +See header comments in each file for detailed setup instructions. + +## Basic Examples + +| File | Description | +|------------------------------------|--------------------------------------------| +| [service.yml](service.yml) | Basic HTTP ingress with multiple domains | +| [service_tls.yml](service_tls.yml) | HTTPS/TLS ingress with custom certificates | + +## Plugin Examples + +| File | Description | +|----------------------------------------------|-----------------------------------------------------| +| [jwt-validator.yml](jwt-validator.yml) | JWT token validation for API protection | +| [ip-whitelist.yml](ip-whitelist.yml) | IP whitelist for admin panels or sensitive services | +| [cloudflare.yml](cloudflare.yml) | Restore real client IPs when behind Cloudflare CDN | +| [plugins-combined.yml](plugins-combined.yml) | Multiple plugins combined for layered security | + +## Documentation Structure + +Each YAML file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Command to apply the manifest +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to remove resources + +## Additional Documentation + +- [Kubernetes Installation Guide](../../docs/kubernetes.md) +- [Helm Installation](../../docs/helm.md) +- [Kubernetes Annotations Reference](../../docs/kubernetes.md#kubernetes-annotations) +- [Using Plugins with Kubernetes](../../docs/kubernetes.md#using-plugins-with-kubernetes) diff --git a/examples/kubernetes/cloudflare.yml b/examples/kubernetes/cloudflare.yml new file mode 100644 index 0000000..b4e037a --- /dev/null +++ b/examples/kubernetes/cloudflare.yml @@ -0,0 +1,134 @@ +# ============================================================================== +# EXAMPLE: Cloudflare IP Restoration Plugin for Kubernetes +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Restoring original visitor IPs when behind Cloudflare CDN +# - Using ConfigMaps to mount Cloudflare IP ranges +# - Detecting requests from Cloudflare IP ranges +# - Accurate client IP logging for applications behind Cloudflare +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Download Cloudflare IP ranges +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# +# # 3. Create ConfigMap with Cloudflare IPs +# kubectl create configmap cloudflare-ips \ +# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ +# -n easyhaproxy +# +# # 4. Mount the ConfigMap in EasyHAProxy deployment: +# # Edit your EasyHAProxy deployment and add: +# # volumeMounts: +# # - name: cloudflare-ips +# # mountPath: /etc/haproxy/cloudflare_ips.lst +# # subPath: cloudflare_ips.lst +# # volumes: +# # - name: cloudflare-ips +# # configMap: +# # name: cloudflare-ips +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f cloudflare.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress -l app=webapp +# +# # Test via port-forward +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: myapp.example.local" http://localhost:8080 +# # Expected: 200 OK with "App Behind Cloudflare" +# +# # In production behind Cloudflare, the plugin will restore real client IPs +# # from the CF-Connecting-IP header +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f cloudflare.yml +# ``` +# +# ============================================================================== + +--- +apiVersion: v1 +kind: Service +metadata: + name: webapp-service + namespace: default +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + app: webapp + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: webapp + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: webapp + template: + metadata: + labels: + app: webapp + spec: + containers: + - name: webapp + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "App Behind Cloudflare" + resources: + limits: + cpu: '0.1' + memory: '64Mi' + requests: + cpu: '0.05' + memory: '32Mi' + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + + # Enable Cloudflare plugin + easyhaproxy.plugins: "cloudflare" + + # Optional: Specify custom IP list path + # easyhaproxy.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" + name: webapp-ingress-cloudflare + namespace: default +spec: + rules: + - host: myapp.example.local + http: + paths: + - backend: + service: + name: webapp-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/ip-whitelist.yml b/examples/kubernetes/ip-whitelist.yml new file mode 100644 index 0000000..c1d5cba --- /dev/null +++ b/examples/kubernetes/ip-whitelist.yml @@ -0,0 +1,121 @@ +# ============================================================================== +# EXAMPLE: IP Whitelist Plugin for Kubernetes +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Restricting access to specific IP addresses or CIDR ranges +# - Using annotations for IP-based access control +# - Protecting admin panels or sensitive services in Kubernetes +# - Custom HTTP status code for blocked requests +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. IMPORTANT: Edit this file (line 80) and update allowed_ips +# # with your actual office/VPN IP addresses or networks +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f ip-whitelist.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress -l app=admin +# +# # Test from allowed IP +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: admin.example.local" http://localhost:8080 +# # Expected: 200 OK with "Admin Panel - IP Restricted" (if your IP is in allowed_ips) +# +# # Test from non-allowed IP +# # Expected: HTTP 403 Forbidden +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f ip-whitelist.yml +# ``` +# +# ============================================================================== + +--- +apiVersion: v1 +kind: Service +metadata: + name: admin-service + namespace: default +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + app: admin + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: admin + template: + metadata: + labels: + app: admin + spec: + containers: + - name: admin + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "Admin Panel - IP Restricted" + resources: + limits: + cpu: '0.1' + memory: '64Mi' + requests: + cpu: '0.05' + memory: '32Mi' + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + + # Enable IP whitelist plugin + easyhaproxy.plugins: "ip_whitelist" + + # Allow specific IPs and networks + # UPDATE THIS with your actual office/VPN IPs! + easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.42,10.0.0.0/8" + + # Status code to return for blocked IPs + easyhaproxy.plugin.ip_whitelist.status_code: "403" + name: admin-ingress-whitelist + namespace: default +spec: + rules: + - host: admin.example.local + http: + paths: + - backend: + service: + name: admin-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/jwt-validator.yml b/examples/kubernetes/jwt-validator.yml new file mode 100644 index 0000000..1a393bd --- /dev/null +++ b/examples/kubernetes/jwt-validator.yml @@ -0,0 +1,141 @@ +# ============================================================================== +# EXAMPLE: JWT Validator Plugin for Kubernetes +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - JWT token validation for API protection in Kubernetes +# - RS256 algorithm signature verification +# - Using ConfigMaps to mount JWT public keys +# - Issuer and audience validation +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Generate RSA key pair (idempotent - skips if exists) +# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# # 3. Create ConfigMap with public key +# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem +# +# # 4. Mount the ConfigMap in EasyHAProxy deployment: +# # Edit your EasyHAProxy deployment and add: +# # volumeMounts: +# # - name: jwt-keys +# # mountPath: /etc/haproxy/jwt_keys +# # volumes: +# # - name: jwt-keys +# # configMap: +# # name: jwt-keys +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f jwt-validator.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress -l app=api +# +# # Test without token (should fail) +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: api.example.local" http://localhost:8080 +# # Expected: HTTP 403 - Missing Authorization HTTP header +# +# # Generate test JWT at https://jwt.io with: +# # - Algorithm: RS256 +# # - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# # - Paste contents of jwt_private.pem in private key field +# +# # Test with valid token +# TOKEN="eyJhbGc..." # Replace with your generated token +# curl -H "Authorization: Bearer $TOKEN" -H "Host: api.example.local" http://localhost:8080 +# # Expected: 200 OK with "Protected API - JWT Required" +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f jwt-validator.yml +# ``` +# +# ============================================================================== + +--- +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: default +spec: + ports: + - port: 8080 + targetPort: 8080 + selector: + app: api + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: byjg/static-httpserver + ports: + - containerPort: 8080 + env: + - name: TITLE + value: "Protected API - JWT Required" + resources: + limits: + cpu: '0.1' + memory: '64Mi' + requests: + cpu: '0.05' + memory: '32Mi' + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + + # Enable JWT validator plugin + easyhaproxy.plugins: "jwt_validator" + + # JWT validator configuration + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + name: api-ingress-jwt + namespace: default +spec: + rules: + - host: api.example.local + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/plugins-combined.yml b/examples/kubernetes/plugins-combined.yml new file mode 100644 index 0000000..e8c76cb --- /dev/null +++ b/examples/kubernetes/plugins-combined.yml @@ -0,0 +1,272 @@ +# ============================================================================== +# EXAMPLE: Multiple Plugins Combined for Kubernetes +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Using multiple security plugins together +# - Different plugin combinations for different services +# - Layered security approach in Kubernetes +# - Three services with different security profiles: +# 1. Public website: Cloudflare + path blocking +# 2. Protected API: JWT validation + path blocking +# 3. Admin panel: Strict IP whitelist +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Generate JWT keys (idempotent - skips if exists) +# [ -f jwt_private.pem ] || openssl genrsa -out jwt_private.pem 2048 +# [ -f jwt_pubkey.pem ] || openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# kubectl create configmap jwt-keys --from-file=api_pubkey.pem=jwt_pubkey.pem +# +# # 3. Download Cloudflare IPs +# curl -s https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# curl -s https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# kubectl create configmap cloudflare-ips \ +# --from-file=cloudflare_ips.lst=cloudflare_ips.lst \ +# -n easyhaproxy +# +# # 4. Mount ConfigMaps in EasyHAProxy deployment +# # (See individual plugin examples for mount configuration) +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f plugins-combined.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check all resources are created +# kubectl get deployment,service,ingress +# +# # Test public website (Cloudflare + path blocking) +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: website.example.local" http://localhost:8080 +# # Expected: 200 OK with "Public Website" +# curl -H "Host: website.example.local" http://localhost:8080/admin +# # Expected: HTTP 404 - Path blocked +# +# # Test protected API (JWT required) +# curl -H "Host: api.example.local" http://localhost:8080 +# # Expected: HTTP 403 - Missing Authorization header +# +# # Test admin panel (IP whitelist) +# curl -H "Host: admin.example.local" http://localhost:8080 +# # Expected: 200 OK from allowed IP, or HTTP 403 from blocked IP +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f plugins-combined.yml +# ``` +# +# ============================================================================== + +--- +# Public website service +apiVersion: v1 +kind: Service +metadata: + name: website-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: website + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: website + namespace: default +spec: + replicas: 3 + selector: + matchLabels: + app: website + template: + metadata: + labels: + app: website + spec: + containers: + - name: website + image: byjg/static-httpserver + env: + - name: TITLE + value: "Public Website" + resources: + requests: + cpu: '0.05' + memory: '32Mi' + +--- +# Public website ingress with Cloudflare + path blocking +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # Cloudflare IP restoration + deny pages + easyhaproxy.plugins: "cloudflare,deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/admin,/wp-admin,/wp-login.php,/.env,/config" + easyhaproxy.plugin.deny_pages.status_code: "404" + name: website-ingress + namespace: default +spec: + rules: + - host: website.example.local + http: + paths: + - backend: + service: + name: website-service + port: + number: 8080 + pathType: ImplementationSpecific + +--- +# API service +apiVersion: v1 +kind: Service +metadata: + name: api-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: api + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api + namespace: default +spec: + replicas: 5 + selector: + matchLabels: + app: api + template: + metadata: + labels: + app: api + spec: + containers: + - name: api + image: byjg/static-httpserver + env: + - name: TITLE + value: "Protected API" + resources: + requests: + cpu: '0.05' + memory: '32Mi' + +--- +# API ingress with JWT + path blocking +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # JWT validation + block internal endpoints + easyhaproxy.plugins: "jwt_validator,deny_pages" + # JWT config + easyhaproxy.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + # Block internal paths + easyhaproxy.plugin.deny_pages.paths: "/internal,/debug,/metrics" + easyhaproxy.plugin.deny_pages.status_code: "403" + name: api-ingress + namespace: default +spec: + rules: + - host: api.example.local + http: + paths: + - backend: + service: + name: api-service + port: + number: 8080 + pathType: ImplementationSpecific + +--- +# Admin service +apiVersion: v1 +kind: Service +metadata: + name: admin-service + namespace: default +spec: + ports: + - port: 8080 + selector: + app: admin + type: ClusterIP + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin + namespace: default +spec: + replicas: 2 + selector: + matchLabels: + app: admin + template: + metadata: + labels: + app: admin + spec: + containers: + - name: admin + image: byjg/static-httpserver + env: + - name: TITLE + value: "Admin Panel" + resources: + requests: + cpu: '0.05' + memory: '32Mi' + +--- +# Admin ingress with strict IP whitelist +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + annotations: + kubernetes.io/ingress.class: easyhaproxy-ingress + # IP whitelist only (strictest security) + easyhaproxy.plugins: "ip_whitelist" + # UPDATE with your office/VPN IPs! + easyhaproxy.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,10.0.0.0/8" + easyhaproxy.plugin.ip_whitelist.status_code: "403" + name: admin-ingress + namespace: default +spec: + rules: + - host: admin.example.local + http: + paths: + - backend: + service: + name: admin-service + port: + number: 8080 + pathType: ImplementationSpecific diff --git a/examples/kubernetes/service.yml b/examples/kubernetes/service.yml index 2ac821a..36fbd31 100644 --- a/examples/kubernetes/service.yml +++ b/examples/kubernetes/service.yml @@ -1,3 +1,57 @@ +# ============================================================================== +# EXAMPLE: Basic Kubernetes Ingress +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Basic ingress configuration with EasyHAProxy +# - Multiple domains pointing to the same service +# - Complete deployment + service + ingress setup +# - HTTP ingress without TLS +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Label the node where EasyHAProxy will run +# kubectl label nodes "easyhaproxy/node=master" +# +# # 3. Add to /etc/hosts for local testing (idempotent) +# grep -q "example.org" /etc/hosts || echo " example.org www.example.org" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f service.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress container-example +# +# # Test via node IP +# curl -H "Host: example.org" http://:31080 +# # Expected: 200 OK with "My Host Example" +# +# # Or use port-forward for testing +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8080:80 +# curl -H "Host: example.org" http://localhost:8080 +# # Expected: 200 OK with "My Host Example" +# +# # Test second domain +# curl -H "Host: www.example.org" http://:31080 +# # Expected: Same response +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f service.yml +# ``` +# +# ============================================================================== + --- apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/examples/kubernetes/service_tls.yml b/examples/kubernetes/service_tls.yml index e351fc2..f0135cb 100644 --- a/examples/kubernetes/service_tls.yml +++ b/examples/kubernetes/service_tls.yml @@ -1,3 +1,58 @@ +# ============================================================================== +# EXAMPLE: TLS/SSL Kubernetes Ingress +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - HTTPS/TLS configuration with custom certificates +# - Kubernetes TLS secrets for SSL certificates +# - Complete deployment + service + secret + ingress with TLS +# - Using pre-generated test certificates +# +# REQUIREMENTS (run these first): +# ```bash +# # 1. Ensure EasyHAProxy is installed in your cluster +# kubectl create namespace easyhaproxy +# kubectl apply -f https://raw.githubusercontent.com/byjg/docker-easy-haproxy/4.6.0/deploy/kubernetes/easyhaproxy-daemonset.yml +# +# # 2. Label the node where EasyHAProxy will run +# kubectl label nodes "easyhaproxy/node=master" +# +# # 3. Add to /etc/hosts for local testing (idempotent) +# grep -q "host2.local" /etc/hosts || echo " host2.local" | sudo tee -a /etc/hosts +# +# # Note: This example uses embedded test certificates +# # For production, create your own secret: +# # kubectl create secret tls host2-tls --cert=cert.crt --key=cert.key +# ``` +# +# HOW TO START: +# ```bash +# kubectl apply -f service_tls.yml +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check resources are created +# kubectl get deployment,service,ingress,secret tls-example +# kubectl get secret host2-tls +# +# # Test HTTPS (using port-forward) +# kubectl port-forward -n easyhaproxy deployment/easyhaproxy 8443:443 +# curl -k -H "Host: host2.local" https://localhost:8443 +# # Expected: 200 OK with "My Host Example" +# +# # Verify certificate +# openssl s_client -showcerts -connect localhost:8443 -servername host2.local < /dev/null +# # Expected: Certificate for host2.local +# ``` +# +# CLEAN UP: +# ```bash +# kubectl delete -f service_tls.yml +# ``` +# +# ============================================================================== + --- apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/examples/static/README.md b/examples/static/README.md new file mode 100644 index 0000000..a1f6c4b --- /dev/null +++ b/examples/static/README.md @@ -0,0 +1,97 @@ +# Static Configuration Example + +Self-contained example for EasyHAProxy using static YAML configuration. **All documentation is in the docker-compose file as header comments.** + +## Quick Start + +1. Open [docker-compose.yml](docker-compose.yml) +2. Read the header comments for complete instructions +3. Choose a configuration scenario from `conf/` directory +4. Run the commands step-by-step + +## What is Static Mode? + +Static mode uses explicit YAML configuration files instead of dynamic service discovery. This is useful for: +- **Non-containerized backends** - VMs, bare metal servers, external APIs +- **Fixed infrastructure** - When your backend IPs/ports don't change +- **Explicit routing control** - Precise control over HAProxy configuration + +## Configuration Files + +All scenarios use `/etc/haproxy/static/config.yml` mounted from `./conf/config.yml`. + +Choose one of these pre-made configurations: + +| Configuration File | Description | +|----------------------------|-------------------------------------------------| +| `config-basic.yml` | Simple HTTP→HTTPS redirect with SSL termination | +| `config-certbot.yml` | Let's Encrypt SSL (requires public domain) | +| `config-deny-pages.yml` | Block specific paths (e.g., `/admin`, `/.env`) | +| `config-jwt-validator.yml` | JWT token validation for API authentication | + +## Prerequisites + +- SSL certificates generated (`./examples/generate-keys.sh`) +- `/etc/hosts` entry for `host1.local` +- Backend container running on port 8080 + +See header comments in [docker-compose.yml](docker-compose.yml) for detailed setup. + +## Documentation Structure + +The docker-compose.yml file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Commands to start backend and EasyHAProxy +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to stop and remove resources + +## Example Workflow + +```bash +# 1. Generate certificates +cd ../.. && ./examples/generate-keys.sh && cd examples/static + +# 2. Choose a configuration +cp conf/config-basic.yml conf/config.yml + +# 3. Start backend +docker run -d --name container -p 8080:8080 byjg/static-httpserver + +# 4. Start EasyHAProxy +docker compose up -d + +# 5. Test +curl -k https://host1.local/ + +# 6. Clean up +docker compose down +docker stop container && docker rm container +``` + +## Configuration File Reference + +Basic structure of `config.yml`: + +```yaml +stats: + username: admin + password: password + port: 1936 + +easymapping: + - port: 443 + ssl: true + hosts: + host1.local: + containers: + - container:8080 # Can also be IP:PORT for external backends +``` + +See `conf/` directory for complete examples. + +## Additional Documentation + +- [Static Configuration Guide](../../docs/static.md) +- [Using Plugins](../../docs/plugins/) +- [Environment Variables](../../docs/environment-variable.md) diff --git a/examples/static/conf/config-basic.yml b/examples/static/conf/config-basic.yml new file mode 100644 index 0000000..ea8d4e1 --- /dev/null +++ b/examples/static/conf/config-basic.yml @@ -0,0 +1,31 @@ +# Basic Static Configuration Example +# +# This is a minimal configuration without plugins +# Demonstrates basic HTTP to HTTPS redirect and SSL setup +# +# To use: +# 1. Update the container name and ports to match your setup +# 2. Place SSL certificate at /certs/haproxy/host1.local.pem +# 3. Mount this config: -v ./conf/config-basic.yml:/etc/haproxy/static/config.yml + +stats: + username: admin + password: password + port: 1936 # Optional (default 1936) + +customerrors: true # Optional (default false) + +easymapping: + # HTTP - Redirect to HTTPS + - port: 80 + redirect: + host1.local: https://host1.local + www.host1.local: https://host1.local + + # HTTPS - Serve application + - port: 443 + ssl: true + hosts: + host1.local: + containers: + - container:8080 diff --git a/examples/static/conf/config-certbot.yml b/examples/static/conf/config-certbot.yml new file mode 100644 index 0000000..b61c389 --- /dev/null +++ b/examples/static/conf/config-certbot.yml @@ -0,0 +1,88 @@ +# Certbot/Let's Encrypt Configuration Example +# +# Demonstrates: +# - Automatic SSL certificate generation with Let's Encrypt +# - HTTP to HTTPS redirect +# - Certificate renewal +# +# Prerequisites: +# 1. Public IP address with ports 80 and 443 accessible +# 2. DNS records pointing to your server: +# example.com -> your-server-ip +# www.example.com -> your-server-ip +# +# 3. Set environment variable: +# EASYHAPROXY_CERTBOT_EMAIL=your-email@example.com +# +# 4. Mount this config: +# -v ./conf/config-certbot.yml:/etc/haproxy/static/config.yml +# +# 5. Persist certificates: +# -v ./certs/certbot:/certs/certbot +# +# How it works: +# - EasyHAProxy requests certificates from Let's Encrypt via HTTP-01 challenge +# - Certificates are stored in /certs/certbot/ +# - Certificates auto-renew when needed +# +# Note: Let's Encrypt has rate limits. Use staging environment for testing: +# EASYHAPROXY_CERTBOT_AUTOCONFIG=staging + +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +easymapping: + # HTTP Port 80 + # Required for ACME HTTP-01 challenge and redirect + - port: 80 + hosts: + # Domain with certbot enabled + example.com: + containers: + - webapp:8080 + # Enable certbot for this domain + certbot: true + # Redirect HTTP to HTTPS after cert is issued + redirect_ssl: true + + # Additional domain with certbot + app.example.com: + containers: + - app:3000 + certbot: true + redirect_ssl: true + + # Domain without certbot (uses custom certificate) + custom.example.com: + containers: + - custom-app:8080 + # No certbot - expects certificate at /certs/haproxy/custom.example.com.pem + + # HTTPS Port 443 + # Serves HTTPS traffic with auto-generated certificates + - port: 443 + ssl: true + hosts: + example.com: + containers: + - webapp:8080 + # Certificate path (auto-generated by certbot) + # /certs/certbot/example.com/fullchain.pem + + app.example.com: + containers: + - app:3000 + + # Custom certificate example + custom.example.com: + containers: + - custom-app:8080 + # Place your certificate at: + # /certs/haproxy/custom.example.com.pem + +# Multiple domains with different backends +# Certbot will request separate certificates for each domain diff --git a/examples/static/conf/config-deny-pages.yml b/examples/static/conf/config-deny-pages.yml new file mode 100644 index 0000000..2eb7d1f --- /dev/null +++ b/examples/static/conf/config-deny-pages.yml @@ -0,0 +1,78 @@ +# Deny Pages Plugin Configuration Example +# +# Demonstrates: +# - Global plugin configuration (applies to all domains) +# - Per-domain plugin override (custom settings per host) +# +# To use: +# 1. Update container names and ports +# 2. Mount this config: -v ./conf/config-deny-pages.yml:/etc/haproxy/static/config.yml +# 3. Test blocked paths: +# curl http://host1.local/admin # Should return 404 +# curl http://host2.local/wp-admin # Should return 403 (different config) + +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +# Global plugin configuration +# This applies to ALL domains unless overridden +plugins: + enabled: + - deny_pages + + config: + deny_pages: + # Global default: block common admin paths with 404 + paths: + - /admin + - /.env + - /config + status_code: 404 # Hide existence of these paths + +easymapping: + - port: 80 + hosts: + # Domain 1: Uses global deny_pages configuration + host1.local: + containers: + - webapp1:8080 + # No plugins specified = uses global configuration + + # Domain 2: WordPress site with custom blocked paths + host2.local: + containers: + - wordpress:80 + # Override global plugin configuration for this domain + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: + - /wp-admin + - /wp-login.php + - /xmlrpc.php + - /wp-config.php + status_code: 403 # Return forbidden instead of 404 + + # Domain 3: Public site with stricter blocking + host3.local: + containers: + - publicsite:3000 + plugins: + - deny_pages + plugin_config: + deny_pages: + paths: + - /admin + - /administrator + - /manager + - /phpmyadmin + - /.git + - /.env + - /config + - /backup + status_code: 404 diff --git a/examples/static/conf/config-jwt-validator.yml b/examples/static/conf/config-jwt-validator.yml new file mode 100644 index 0000000..76319c0 --- /dev/null +++ b/examples/static/conf/config-jwt-validator.yml @@ -0,0 +1,86 @@ +# JWT Validator Plugin Configuration Example +# +# Demonstrates: +# - JWT token validation for API protection +# - Different JWT configurations per domain +# - Optional issuer/audience validation +# +# Prerequisites: +# 1. Generate RSA key pair: +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# 2. Mount public keys: +# -v ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro +# -v ./jwt_pubkey2.pem:/etc/haproxy/jwt_keys/admin_pubkey.pem:ro +# +# 3. Mount this config: +# -v ./conf/config-jwt-validator.yml:/etc/haproxy/static/config.yml +# +# 4. Test: +# # Without token - should fail +# curl http://api.local/users +# # Response: Missing Authorization HTTP header +# +# # With valid token - should succeed +# curl -H "Authorization: Bearer eyJhbGc..." http://api.local/users + +stats: + username: admin + password: password + port: 1936 + +customerrors: true + +easymapping: + - port: 80 + hosts: + # Public API with full JWT validation + api.local: + containers: + - api-server:8080 + plugins: + - jwt_validator + plugin_config: + jwt_validator: + algorithm: RS256 + issuer: https://auth.example.com/ + audience: https://api.example.com + pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + + # Internal API - validate signature only (no issuer/audience check) + internal-api.local: + containers: + - internal-api:3000 + plugins: + - jwt_validator + plugin_config: + jwt_validator: + algorithm: RS256 + # No issuer/audience = skip those validations + pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + + # Admin API - different issuer and key + admin-api.local: + containers: + - admin-api:4000 + plugins: + - jwt_validator + - deny_pages # Also block internal paths + plugin_config: + jwt_validator: + algorithm: RS256 + issuer: https://admin-auth.example.com/ + audience: https://admin.example.com + pubkey_path: /etc/haproxy/jwt_keys/admin_pubkey.pem + deny_pages: + paths: + - /internal + - /debug + status_code: 403 + + # Public website - no JWT required + website.local: + containers: + - website:8080 + # No plugins = public access diff --git a/examples/static/conf/config.yml b/examples/static/conf/config.yml deleted file mode 100644 index a3e2d18..0000000 --- a/examples/static/conf/config.yml +++ /dev/null @@ -1,19 +0,0 @@ -stats: - username: admin - password: password - port: 1936 # Optional (default 1936) - -customerrors: true # Optional (default false) - -easymapping: - - port: 80 - redirect: - host1.local: https://host1.local - www.host1.local: https://host1.local - - - port: 443 - ssl: true - hosts: - host1.local: - containers: - - container:8080 diff --git a/examples/static/docker-compose.yml b/examples/static/docker-compose.yml index 809379b..92f9966 100644 --- a/examples/static/docker-compose.yml +++ b/examples/static/docker-compose.yml @@ -1,7 +1,62 @@ -# To test: +# ============================================================================== +# EXAMPLE: Static Configuration Mode +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - EasyHAProxy using static YAML configuration (no service discovery) +# - Useful for non-containerized backends, VMs, or bare metal servers +# - Configuration via /etc/haproxy/static/config.yml +# +# REQUIREMENTS (run these first): +# ```bash +# # Generate SSL certificates +# cd ../.. && ./examples/generate-keys.sh && cd examples/static +# +# # Add to /etc/hosts (idempotent) +# grep -q "host1.local" /etc/hosts || echo "127.0.0.1 host1.local" | sudo tee -a /etc/hosts +# +# # Copy a configuration file (choose one): +# cp conf/config-basic.yml conf/config.yml # Basic HTTP→HTTPS redirect +# # OR +# cp conf/config-certbot.yml conf/config.yml # Let's Encrypt (requires public domain) +# # OR +# cp conf/config-deny-pages.yml conf/config.yml # Block specific paths +# # OR +# cp conf/config-jwt-validator.yml conf/config.yml # JWT authentication +# ``` +# +# HOW TO START: +# ```bash +# # Start backend container +# docker run -d --name container -p 8080:8080 byjg/static-httpserver +# +# # Start EasyHAProxy +# docker compose up -d +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Test HTTPS # curl -k -H "Host: host1.local" https://127.0.0.1/ - -version: "3" +# # Expected: 200 OK with "Hello from Static HTTP Server!" +# +# # Test HTTP redirect (if using basic config) +# curl -I -H "Host: host1.local" http://127.0.0.1 +# # Expected: HTTP/1.1 301 Moved Permanently +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker compose down +# docker stop container && docker rm container +# ``` +# +# ============================================================================== services: haproxy: diff --git a/examples/static/host1.local.pem b/examples/static/host1.local.pem deleted file mode 100644 index 0d7eb39..0000000 --- a/examples/static/host1.local.pem +++ /dev/null @@ -1,82 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFDTCCAvWgAwIBAgIURi+w1ZVgeedTlNIAwqQBMJv6dXswDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAwwLaG9zdDEubG9jYWwwHhcNMjEwODEwMTg0OTA2WhcNMzEw -ODA4MTg0OTA2WjAWMRQwEgYDVQQDDAtob3N0MS5sb2NhbDCCAiIwDQYJKoZIhvcN -AQEBBQADggIPADCCAgoCggIBAMBDAhLAygJuaW6w6ffigzTAAGXpmEz0tIxn1k4Z -x5wN5rpv/qu0QMYz+Av2u1eOKEKZeaFRVpT0r93dX7IvbEZHt25GPiBvlLGqhjKR -PnSk/7U8XmsnttUAV7rVEK1UrdFw8/IwriQC+dhr0mnYfSDMkvBoMFpdhVNTrbAZ -1TB6rQjE7Ar0Mt8my96XJmwrcjK2Tj+E2rgPIUz1e5cekFYIDSBatmw+3+vr+T5x -FNFkJ2o30W5o8ZflCJJzrVaihqQics6ZKDgpf7iqXMFiwWIlhdQpGvx5Gf/KFTK9 -UaOnRZz/X+2CebAFaTHR3k/PYppWTgBBBuRvlpCw+wdnkmteC0SQRF91QWVr7ejo -7KaOlGI5VtvMUsWvTeAZmpaymIaATETuOJaY0JU11OmLeD9DOj5E2SQ7qIX/pFcp -xpzG5j4c+MlgvxP2VAkNTeAXCaYiPBQH5ZZg0HE2WnB1KhLRFlHd4iHQD2GJ5yN/ -6fCFBfZfKSeK8JauwxgWkra53OcDq/mKd+DA/dK+/ruG7tqwVgIa04HOplzM7LYR -GB0Irs9+lr5/PJbQZmU073Mdn6cXAg3p+6wvwFlDkS5v13gBDYNHtF62bc551edF -Z6kGzJ7wmGRo84aBP7MuRZeReLOrSS67a1wLdzZsMnP1TJ7x9Lfr9MKl2uDnQdnY -ex8DAgMBAAGjUzBRMB0GA1UdDgQWBBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAfBgNV -HSMEGDAWgBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAPBgNVHRMBAf8EBTADAQH/MA0G -CSqGSIb3DQEBCwUAA4ICAQChQYNuah3+mTpIBDYxGrjTJNuOTIMaWzMyi1tkf+L0 -sEGwpbmAO2mWWQYF7WVLsi98PULh3adjt2jiud9VlaaC6gnwn5Zo1+Pilo9sNLLW -6ij0+rN4kwIm/pNqi+jDuu2cvAuHIwZWeh8bEe/5UCxo4ihmWFQN8eJ6TUKCphRC -6Eor/SSZZBQHgPl0BchzHOkwu7R3LCndRqxjhAoVb9yQOV+ZsmTeJXulwNzJ1uLt -T8OIgIiDpmBo7HSN2H0k3chx00AsjUyJ9mmAWPejFe/KXLRPcVZR17jhzgfIBEzs -M5WtWFm1aHDjVv6M6iteVm61E9T+k/M11ru1e2YwsxTDvb6x04mcrNu9soqddBbr -VfpluuoQ/hEAbXtFNPoTySpz0cwOwcHCowVOLmdKgvImszZiMyHHG8VGGmPh88n7 -wVxb0gV0P4RMrcMLdeTdn55YQr1CqBr34eB6ol6AsbTm3VzBHRVmFNksl1o5JB5t -tXLgF/G8/rzJ/4m1PaVuxrB7DxUmIk8EPbSIVkvZvd7LBzKwQ6IfVaucewHfEajQ -VIiexSMiFc7lw3KnxjOHZjf6FM9VYg3No++GdC99s7LkIuJwAMLNqTQ7Hvhn7YvP -4FlSIgc6xj0YkGZEQlb5o/5nauEqQU0ABgw6jtI4NxrNLT6cp7CO4M0xIDEg/3YD -aA== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDAQwISwMoCbmlu -sOn34oM0wABl6ZhM9LSMZ9ZOGcecDea6b/6rtEDGM/gL9rtXjihCmXmhUVaU9K/d -3V+yL2xGR7duRj4gb5SxqoYykT50pP+1PF5rJ7bVAFe61RCtVK3RcPPyMK4kAvnY -a9Jp2H0gzJLwaDBaXYVTU62wGdUweq0IxOwK9DLfJsvelyZsK3Iytk4/hNq4DyFM -9XuXHpBWCA0gWrZsPt/r6/k+cRTRZCdqN9FuaPGX5QiSc61WooakInLOmSg4KX+4 -qlzBYsFiJYXUKRr8eRn/yhUyvVGjp0Wc/1/tgnmwBWkx0d5Pz2KaVk4AQQbkb5aQ -sPsHZ5JrXgtEkERfdUFla+3o6OymjpRiOVbbzFLFr03gGZqWspiGgExE7jiWmNCV -NdTpi3g/Qzo+RNkkO6iF/6RXKcacxuY+HPjJYL8T9lQJDU3gFwmmIjwUB+WWYNBx -NlpwdSoS0RZR3eIh0A9hiecjf+nwhQX2XyknivCWrsMYFpK2udznA6v5infgwP3S -vv67hu7asFYCGtOBzqZczOy2ERgdCK7Pfpa+fzyW0GZlNO9zHZ+nFwIN6fusL8BZ -Q5Eub9d4AQ2DR7Retm3OedXnRWepBsye8JhkaPOGgT+zLkWXkXizq0kuu2tcC3c2 -bDJz9Uye8fS36/TCpdrg50HZ2HsfAwIDAQABAoICAQC/xZbZ0cctqagsqvaVNTEe -eq1q+hfaGvPEYQaYHIrIE+2i5XcnGcLKcKfodxDjAn8R/zgdOp6cMX0CVn/PohHk -AEDtE8+AVwwAM1FsOwgLHVGaGz8qrxBlYdQgHcpmueIu2PXbC8eHUBiaUOIuhaw5 -/RRMDAC/Ai2ssfi7gOjvVE4oQxQW0QG1KGOOAUJn/uYHw2RFY2Uu1pimxO2kDO53 -gcxmC1WOnyCHmHaiW/Uh7z6JamfSM4dXtTJZslyh37dhHKNbg9VkP7CQKA4hLzop -hbf5qY6rargONiny1HgMPxrmwKuUouJyOtN0yBtxjDCUNaXUBwiy7sNGS+H4vsyB -5P9HhIHStu+FZt3HG7EIqCndiaSKDS4jWaVQAbbo4nZ2Zs2BD+xDePRCRUqX7rM4 -4XzPIRWWXmmWf/7Ig29Hbrp4a9LcOmQ2leCJtbaTFSN96OLUJ5E+hQ0ulCZgBVmQ -RCUYkJP4lOzbaKdzjxgHMrHzm45eUFf8LirOxi2uyxXHQmDNu4b3X18kt3PgUmUm -3dXpl3fqSyJa7SCV8ZNBrsrDq1E+thYtu91QbVSGxHd9HrNVe3XdLbOCdU9CuC69 -Nglznaa7sZLqmyKejTfGsY7xrWdNcMPl4p4fcID/O4EpASZforpTeKNT0ZIfZZew -b0mAQeYZqQM8i/qMYN/uAQKCAQEA5qg1sRNMc6VdM/tRglasGYoxjgRC2OqADZgs -mAXMUJ3kErpyxt+eCimy8ibuYpzRTIQ8fBTWRkCtRZXJ7+KcLVtk9QZIoLbhyNwd -4IxEQZFuUljDbvSjTLSycsHvo65ibWIfTL7bgWlLGgGq/UOzfGsgH6S9wLp5G30G -8ELyjI5eTIYICrfTmVL+c45MRpEMKo+cvz8PysiaOFTn3cyswPVdYaeEEqMQjU8w -IGNsGZLytY7BABBcY0ldrtba/O+Fv/+RH7uUtzP7xpCIwFCx80ZzN+WRy9NvI63U -zq3yIBoW9GyApD2+PLaPNxf7QLTUChY1Zz/dYRltKOxv2Aa5gQKCAQEA1WLWNqp0 -fhB/ZtfSEShxFMM89cjN6Aaz1WKL7uTBou9oSJnxjkhkaV76acnT/iqXtxMNgHi1 -fImDpU3PvM0Y4Ud2T47oHc6P1BrZPN/GmXy/s6BAEdPwLe7J+4nTISHAdGmrh+a/ -5pktu32g9lWqftxecFIVSLPWkxT0XKiMxp1ffkL+OavpMgMFZK41iKs3dNShKPog -L8GSPcP9x/yn78P2eK3N+PGjlA6pPzrANyWU7N0/bmHcB9TKP+udYWcjVhru7MYN -wNrE4kKdC8v8i7x7tDbvb79T+Fo6PIh53p0OsnZzA8UR0QNR+vDQufQuyaj8REC+ -ZG8YyCKsvk8ygwKCAQA/fsSxB0f/eeErYx6wC536teEoYCHqxrsTgvWbr9TryFs1 -kJ/yATLnR01cfb0X5mVzc9+WpMHLuxg31KEvaSlnDwa+sMkjfNSwz29mFhbgGeHN -x2OdUrj1b7TEBIEshN/RjrZhERUqDcs/0H+6kn2BXZgNPfOCb5LRL1zOnQ9aBAMP -e8IQ+UPFrGQheWWj81/vA3O57ekyAID7ytu9Yg+YWrMnI88mtj7jN45fDB+A9sPb -mP2mP9q+9j5U2A6WnHUsQnU30BKDUEsaAUWz80LZXmZvV8IH4x9wKfUwJBBIKAZz -qL7M97Y7zmGkX/Spfl30nOJ8lschaLd1EYlEZa2BAoIBAQCye25T4TV5MJFv0zuZ -MGuNg1Sc/O4Fkn2fEUOceWjhwUBH4cPjT/f1DwWDsNaJ9NRbxCr5931OArPDc5c8 -A404+Y4jM5RBQkKZli94tHAod+jc9UBB6TUvJll59SlMwC9679wS21ZOKnfPKGCX -SsZGQEsZxf6ZhhsHgXJ3gl/lzUJPmPeOA5YVR+Od9/09KIFFTojSfoynhVCuKx49 -xb4uVYn2HOJ4xJ0fPTghdCHMvrmXeeQRjvb88eaNmqVUEHHFFtgb4fklA5fE7RTx -BhliRDBwZ7bUkINK6yVk9n6BTns5mMvRLmgdnJpYvE7KC02LTbZb3I+j8C0ZUa+N -qy7DAoIBAAieribS7WUcl2aBlkm5+W7qNm/INm5zvnoSPo6V3wa5hs6f9+C/kbdF -87jQPA/YFe3uR2sAJ7slX5euZK8WmfpFmgzlu0sEz81MLQ/WypZtZytyVtWzB2Pu -XCW1tdSH9eI2BmhXgokHNTM48Nk/xOENrP/seXrIx5LK0hnDHZotu/z6+YSkB9hF -cm2fZygD1dMLX6liRimxyFY+dICJNB95JifTLWYnWeGddkwPtXUeGXE1olzvNkLD -zMzE09uhkx/lRJnteOBEZaf80OB/09Oi9b9/rxY59dwsH6GaxLoTfEKuPnvBVMNR -YkU14WzQKleFkiBJI9lVvnfgGnOlgg0= ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/swarm/README.md b/examples/swarm/README.md new file mode 100644 index 0000000..64d67ef --- /dev/null +++ b/examples/swarm/README.md @@ -0,0 +1,67 @@ +# Docker Swarm Examples + +Self-contained examples for EasyHAProxy in Docker Swarm mode. **All documentation is in the YAML files as header comments.** + +## Quick Start + +1. Pick an example below +2. Open the YAML file +3. Read the header comments for complete instructions +4. Run the commands step-by-step + +## Prerequisites + +All examples require: +- Docker Swarm initialized (`docker swarm init`) +- Overlay network created (`docker network create --driver overlay --attachable easyhaproxy`) +- EasyHAProxy deployed (`docker stack deploy -c easyhaproxy.yml easyhaproxy`) + +See header comments in each file for detailed setup instructions. + +## Basic Examples + +| File | Description | +|------|-------------| +| [easyhaproxy.yml](easyhaproxy.yml) | EasyHAProxy service for Swarm with stats and certbot | +| [services.yml](services.yml) | Basic services with SSL (embedded cert and file-based) | +| [portainer.yml](portainer.yml) | Portainer management UI behind EasyHAProxy | + +## Plugin Examples + +| File | Description | +|------|-------------| +| [jwt-validator.yml](jwt-validator.yml) | JWT token validation for API protection | +| [ip-whitelist.yml](ip-whitelist.yml) | IP whitelist for admin panels or sensitive services | +| [cloudflare.yml](cloudflare.yml) | Restore real client IPs when behind Cloudflare CDN | +| [plugins-combined.yml](plugins-combined.yml) | Multiple plugins combined for layered security | + +## Documentation Structure + +Each YAML file contains: +- **WHAT THIS DEMONSTRATES** - Key features and concepts +- **REQUIREMENTS** - Idempotent setup commands (safe to run multiple times) +- **HOW TO START** - Command to deploy the stack +- **HOW TO VERIFY IT'S WORKING** - Test commands with expected outputs +- **CLEAN UP** - Commands to remove resources + +## Important: Service Labels + +In Swarm mode, labels must be under `deploy.labels`, NOT top-level `labels`: + +```yaml +# ✅ CORRECT - Service labels +deploy: + labels: + easyhaproxy.http.host: example.com + +# ❌ WRONG - Container labels (ignored in Swarm) +labels: + easyhaproxy.http.host: example.com +``` + +## Additional Documentation + +- [Docker Swarm Guide](../../docs/swarm.md) +- [Container Labels Reference](../../docs/container-labels.md) +- [Using Plugins](../../docs/plugins/) +- [ACME/Let's Encrypt](../../docs/acme.md) diff --git a/examples/swarm/certs/host1.local.pem b/examples/swarm/certs/host1.local.pem deleted file mode 100644 index 0d7eb39..0000000 --- a/examples/swarm/certs/host1.local.pem +++ /dev/null @@ -1,82 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFDTCCAvWgAwIBAgIURi+w1ZVgeedTlNIAwqQBMJv6dXswDQYJKoZIhvcNAQEL -BQAwFjEUMBIGA1UEAwwLaG9zdDEubG9jYWwwHhcNMjEwODEwMTg0OTA2WhcNMzEw -ODA4MTg0OTA2WjAWMRQwEgYDVQQDDAtob3N0MS5sb2NhbDCCAiIwDQYJKoZIhvcN -AQEBBQADggIPADCCAgoCggIBAMBDAhLAygJuaW6w6ffigzTAAGXpmEz0tIxn1k4Z -x5wN5rpv/qu0QMYz+Av2u1eOKEKZeaFRVpT0r93dX7IvbEZHt25GPiBvlLGqhjKR -PnSk/7U8XmsnttUAV7rVEK1UrdFw8/IwriQC+dhr0mnYfSDMkvBoMFpdhVNTrbAZ -1TB6rQjE7Ar0Mt8my96XJmwrcjK2Tj+E2rgPIUz1e5cekFYIDSBatmw+3+vr+T5x -FNFkJ2o30W5o8ZflCJJzrVaihqQics6ZKDgpf7iqXMFiwWIlhdQpGvx5Gf/KFTK9 -UaOnRZz/X+2CebAFaTHR3k/PYppWTgBBBuRvlpCw+wdnkmteC0SQRF91QWVr7ejo -7KaOlGI5VtvMUsWvTeAZmpaymIaATETuOJaY0JU11OmLeD9DOj5E2SQ7qIX/pFcp -xpzG5j4c+MlgvxP2VAkNTeAXCaYiPBQH5ZZg0HE2WnB1KhLRFlHd4iHQD2GJ5yN/ -6fCFBfZfKSeK8JauwxgWkra53OcDq/mKd+DA/dK+/ruG7tqwVgIa04HOplzM7LYR -GB0Irs9+lr5/PJbQZmU073Mdn6cXAg3p+6wvwFlDkS5v13gBDYNHtF62bc551edF -Z6kGzJ7wmGRo84aBP7MuRZeReLOrSS67a1wLdzZsMnP1TJ7x9Lfr9MKl2uDnQdnY -ex8DAgMBAAGjUzBRMB0GA1UdDgQWBBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAfBgNV -HSMEGDAWgBSQ/mtZd6h8en9YQVH6HO1PlWWiqzAPBgNVHRMBAf8EBTADAQH/MA0G -CSqGSIb3DQEBCwUAA4ICAQChQYNuah3+mTpIBDYxGrjTJNuOTIMaWzMyi1tkf+L0 -sEGwpbmAO2mWWQYF7WVLsi98PULh3adjt2jiud9VlaaC6gnwn5Zo1+Pilo9sNLLW -6ij0+rN4kwIm/pNqi+jDuu2cvAuHIwZWeh8bEe/5UCxo4ihmWFQN8eJ6TUKCphRC -6Eor/SSZZBQHgPl0BchzHOkwu7R3LCndRqxjhAoVb9yQOV+ZsmTeJXulwNzJ1uLt -T8OIgIiDpmBo7HSN2H0k3chx00AsjUyJ9mmAWPejFe/KXLRPcVZR17jhzgfIBEzs -M5WtWFm1aHDjVv6M6iteVm61E9T+k/M11ru1e2YwsxTDvb6x04mcrNu9soqddBbr -VfpluuoQ/hEAbXtFNPoTySpz0cwOwcHCowVOLmdKgvImszZiMyHHG8VGGmPh88n7 -wVxb0gV0P4RMrcMLdeTdn55YQr1CqBr34eB6ol6AsbTm3VzBHRVmFNksl1o5JB5t -tXLgF/G8/rzJ/4m1PaVuxrB7DxUmIk8EPbSIVkvZvd7LBzKwQ6IfVaucewHfEajQ -VIiexSMiFc7lw3KnxjOHZjf6FM9VYg3No++GdC99s7LkIuJwAMLNqTQ7Hvhn7YvP -4FlSIgc6xj0YkGZEQlb5o/5nauEqQU0ABgw6jtI4NxrNLT6cp7CO4M0xIDEg/3YD -aA== ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIJQwIBADANBgkqhkiG9w0BAQEFAASCCS0wggkpAgEAAoICAQDAQwISwMoCbmlu -sOn34oM0wABl6ZhM9LSMZ9ZOGcecDea6b/6rtEDGM/gL9rtXjihCmXmhUVaU9K/d -3V+yL2xGR7duRj4gb5SxqoYykT50pP+1PF5rJ7bVAFe61RCtVK3RcPPyMK4kAvnY -a9Jp2H0gzJLwaDBaXYVTU62wGdUweq0IxOwK9DLfJsvelyZsK3Iytk4/hNq4DyFM -9XuXHpBWCA0gWrZsPt/r6/k+cRTRZCdqN9FuaPGX5QiSc61WooakInLOmSg4KX+4 -qlzBYsFiJYXUKRr8eRn/yhUyvVGjp0Wc/1/tgnmwBWkx0d5Pz2KaVk4AQQbkb5aQ -sPsHZ5JrXgtEkERfdUFla+3o6OymjpRiOVbbzFLFr03gGZqWspiGgExE7jiWmNCV -NdTpi3g/Qzo+RNkkO6iF/6RXKcacxuY+HPjJYL8T9lQJDU3gFwmmIjwUB+WWYNBx -NlpwdSoS0RZR3eIh0A9hiecjf+nwhQX2XyknivCWrsMYFpK2udznA6v5infgwP3S -vv67hu7asFYCGtOBzqZczOy2ERgdCK7Pfpa+fzyW0GZlNO9zHZ+nFwIN6fusL8BZ -Q5Eub9d4AQ2DR7Retm3OedXnRWepBsye8JhkaPOGgT+zLkWXkXizq0kuu2tcC3c2 -bDJz9Uye8fS36/TCpdrg50HZ2HsfAwIDAQABAoICAQC/xZbZ0cctqagsqvaVNTEe -eq1q+hfaGvPEYQaYHIrIE+2i5XcnGcLKcKfodxDjAn8R/zgdOp6cMX0CVn/PohHk -AEDtE8+AVwwAM1FsOwgLHVGaGz8qrxBlYdQgHcpmueIu2PXbC8eHUBiaUOIuhaw5 -/RRMDAC/Ai2ssfi7gOjvVE4oQxQW0QG1KGOOAUJn/uYHw2RFY2Uu1pimxO2kDO53 -gcxmC1WOnyCHmHaiW/Uh7z6JamfSM4dXtTJZslyh37dhHKNbg9VkP7CQKA4hLzop -hbf5qY6rargONiny1HgMPxrmwKuUouJyOtN0yBtxjDCUNaXUBwiy7sNGS+H4vsyB -5P9HhIHStu+FZt3HG7EIqCndiaSKDS4jWaVQAbbo4nZ2Zs2BD+xDePRCRUqX7rM4 -4XzPIRWWXmmWf/7Ig29Hbrp4a9LcOmQ2leCJtbaTFSN96OLUJ5E+hQ0ulCZgBVmQ -RCUYkJP4lOzbaKdzjxgHMrHzm45eUFf8LirOxi2uyxXHQmDNu4b3X18kt3PgUmUm -3dXpl3fqSyJa7SCV8ZNBrsrDq1E+thYtu91QbVSGxHd9HrNVe3XdLbOCdU9CuC69 -Nglznaa7sZLqmyKejTfGsY7xrWdNcMPl4p4fcID/O4EpASZforpTeKNT0ZIfZZew -b0mAQeYZqQM8i/qMYN/uAQKCAQEA5qg1sRNMc6VdM/tRglasGYoxjgRC2OqADZgs -mAXMUJ3kErpyxt+eCimy8ibuYpzRTIQ8fBTWRkCtRZXJ7+KcLVtk9QZIoLbhyNwd -4IxEQZFuUljDbvSjTLSycsHvo65ibWIfTL7bgWlLGgGq/UOzfGsgH6S9wLp5G30G -8ELyjI5eTIYICrfTmVL+c45MRpEMKo+cvz8PysiaOFTn3cyswPVdYaeEEqMQjU8w -IGNsGZLytY7BABBcY0ldrtba/O+Fv/+RH7uUtzP7xpCIwFCx80ZzN+WRy9NvI63U -zq3yIBoW9GyApD2+PLaPNxf7QLTUChY1Zz/dYRltKOxv2Aa5gQKCAQEA1WLWNqp0 -fhB/ZtfSEShxFMM89cjN6Aaz1WKL7uTBou9oSJnxjkhkaV76acnT/iqXtxMNgHi1 -fImDpU3PvM0Y4Ud2T47oHc6P1BrZPN/GmXy/s6BAEdPwLe7J+4nTISHAdGmrh+a/ -5pktu32g9lWqftxecFIVSLPWkxT0XKiMxp1ffkL+OavpMgMFZK41iKs3dNShKPog -L8GSPcP9x/yn78P2eK3N+PGjlA6pPzrANyWU7N0/bmHcB9TKP+udYWcjVhru7MYN -wNrE4kKdC8v8i7x7tDbvb79T+Fo6PIh53p0OsnZzA8UR0QNR+vDQufQuyaj8REC+ -ZG8YyCKsvk8ygwKCAQA/fsSxB0f/eeErYx6wC536teEoYCHqxrsTgvWbr9TryFs1 -kJ/yATLnR01cfb0X5mVzc9+WpMHLuxg31KEvaSlnDwa+sMkjfNSwz29mFhbgGeHN -x2OdUrj1b7TEBIEshN/RjrZhERUqDcs/0H+6kn2BXZgNPfOCb5LRL1zOnQ9aBAMP -e8IQ+UPFrGQheWWj81/vA3O57ekyAID7ytu9Yg+YWrMnI88mtj7jN45fDB+A9sPb -mP2mP9q+9j5U2A6WnHUsQnU30BKDUEsaAUWz80LZXmZvV8IH4x9wKfUwJBBIKAZz -qL7M97Y7zmGkX/Spfl30nOJ8lschaLd1EYlEZa2BAoIBAQCye25T4TV5MJFv0zuZ -MGuNg1Sc/O4Fkn2fEUOceWjhwUBH4cPjT/f1DwWDsNaJ9NRbxCr5931OArPDc5c8 -A404+Y4jM5RBQkKZli94tHAod+jc9UBB6TUvJll59SlMwC9679wS21ZOKnfPKGCX -SsZGQEsZxf6ZhhsHgXJ3gl/lzUJPmPeOA5YVR+Od9/09KIFFTojSfoynhVCuKx49 -xb4uVYn2HOJ4xJ0fPTghdCHMvrmXeeQRjvb88eaNmqVUEHHFFtgb4fklA5fE7RTx -BhliRDBwZ7bUkINK6yVk9n6BTns5mMvRLmgdnJpYvE7KC02LTbZb3I+j8C0ZUa+N -qy7DAoIBAAieribS7WUcl2aBlkm5+W7qNm/INm5zvnoSPo6V3wa5hs6f9+C/kbdF -87jQPA/YFe3uR2sAJ7slX5euZK8WmfpFmgzlu0sEz81MLQ/WypZtZytyVtWzB2Pu -XCW1tdSH9eI2BmhXgokHNTM48Nk/xOENrP/seXrIx5LK0hnDHZotu/z6+YSkB9hF -cm2fZygD1dMLX6liRimxyFY+dICJNB95JifTLWYnWeGddkwPtXUeGXE1olzvNkLD -zMzE09uhkx/lRJnteOBEZaf80OB/09Oi9b9/rxY59dwsH6GaxLoTfEKuPnvBVMNR -YkU14WzQKleFkiBJI9lVvnfgGnOlgg0= ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/swarm/certs/host2.local.pem b/examples/swarm/certs/host2.local.pem deleted file mode 100644 index 917062a..0000000 --- a/examples/swarm/certs/host2.local.pem +++ /dev/null @@ -1,50 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDqTCCApGgAwIBAgIUId5b9t9uqH78g02EzbWF6FKVw3gwDQYJKoZIhvcNAQEL -BQAwZDELMAkGA1UEBhMCQlIxFzAVBgNVBAgMDlJpbyBkZSBKYW5laXJvMRcwFQYD -VQQHDA5SaW8gZGUgSmFuZWlybzENMAsGA1UECgwEQUNNRTEUMBIGA1UEAwwLaG9z -dDIubG9jYWwwHhcNMjIwODE1MDQyNzA1WhcNMjMwODE1MDQyNzA1WjBkMQswCQYD -VQQGEwJCUjEXMBUGA1UECAwOUmlvIGRlIEphbmVpcm8xFzAVBgNVBAcMDlJpbyBk -ZSBKYW5laXJvMQ0wCwYDVQQKDARBQ01FMRQwEgYDVQQDDAtob3N0Mi5sb2NhbDCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMSKvrOahavCXnvSF5131hpo -6k65C57jgRQ84FaDj5MbJOVlYQVFtMG0XOk7a+hh5v1fe4wH0R7I6FDo0V9sS+ss -ko5bsElc1xYlg5HbuKq89vRSKg6EDlztx3BKbi912Pmt5vFGNJ16zcw77DUrQIXo -4I/b4a3pmBiWj43NoTIrmSWHtsGwwOj3iDvSweqdYXJIr3hpHH5u6pohjDoQvqDz -K6Mu8p6mhCUKNs7KFJnNInNG25oQT6O0n4OGtmgRjLWopdEnOhMkKsfIoI1XtlXB -LBDv7huICk3t5ywtfCQyO09kX7lFIgd5rn7+MjwH5WNeqbQJxuaqjoXQnNZUgUsC -AwEAAaNTMFEwHQYDVR0OBBYEFNhMBG8q6a+iK2nECwVTn6B9EXZOMB8GA1UdIwQY -MBaAFNhMBG8q6a+iK2nECwVTn6B9EXZOMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI -hvcNAQELBQADggEBAJmudvx8+p5iIUsT8fm/fbVM0DA6qWALDYUJnTn3j6Lq4vpf -PFC+q1LmuWfBQMyqKrHrP3e493EctXoiSKZO6iN5dVJIur02OjGuiAEcsYuY1nLn -s9piiI+UEwxH6ux1NaHUnzsWauoBvRhzjXvO6SAVSZJYa9dY5mizXklDyDNuG5U0 -lXv9egMGBsy0dG6eFXkU5CPdxWU540yI2sCtSAj7z+WRUD5k7gJ7tVoY3//jHQZG -5STTmm5t9kpIZTWkptyJos9oZJFYMIXqW2Fc6tyLZpRp31R78tDs6ETIkToDc0RR -jz66th6HI+ZlgIBQhw09+hYAhBDe9+Dmd/SzQZc= ------END CERTIFICATE----- ------BEGIN PRIVATE KEY----- -MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDEir6zmoWrwl57 -0hedd9YaaOpOuQue44EUPOBWg4+TGyTlZWEFRbTBtFzpO2voYeb9X3uMB9EeyOhQ -6NFfbEvrLJKOW7BJXNcWJYOR27iqvPb0UioOhA5c7cdwSm4vddj5rebxRjSdes3M -O+w1K0CF6OCP2+Gt6ZgYlo+NzaEyK5klh7bBsMDo94g70sHqnWFySK94aRx+buqa -IYw6EL6g8yujLvKepoQlCjbOyhSZzSJzRtuaEE+jtJ+DhrZoEYy1qKXRJzoTJCrH -yKCNV7ZVwSwQ7+4biApN7ecsLXwkMjtPZF+5RSIHea5+/jI8B+VjXqm0Ccbmqo6F -0JzWVIFLAgMBAAECggEBAKceitVROQQ5e/mxRR9CfK1sNH/H3Ne3/1PkB6XIrFab -qB3evEatZOuon7A6NKEeTjl37Se+pdSVZOUXcqC/BzbraZre3+EhrkpIj72ApV+Y -2iwZiWVaaJQgI4uZ3mNAw8RaWJsj5S1a9I8LDOiQ5IZ45CmvABDPJeMScvJSvRRY -e5N0L6stqS7Z+IoyGVKUfp1iNO0YyywOUiSkIRXgscuRXZGYpiGPomsJ+Js1ejzW -jyStlZJEr4L1285rGPrmHqjTwFd+hG80Wc4179xL+WRE6HBEUZSiy95fe6kcPHXX -BgiVYtcFKmiBi2dTbxl4e94ut239i0HtlJ1ZJtLh+BECgYEA8a298K2zXkHosxhN -tRrH7XfMPTkHDDd3rxM21LT+fIXqinGUp9LYaDcbjuTPs8e33uKMd7R6Q40x89yW -IXNka/VL0PXUeV67aCVLLqgXDLGudluJinH0XvmI0CmBecFSMqIFmQlgqERoGGs3 -UMac0p876T4XkGQQJdf62bFpE4UCgYEA0DBH0PDlOpwXccDgXrMfayr8HAhI+G5R -yWQ//9iirtU83chwIWwkh53eLLMzLgdqJnPiWyaUW5BqzmYuD23nhxcQ7PNdIqOO -H1sE6zqLNshv46t5QKlh1Q4qjd7UqtgrSrY63RXJCMWTwnNMeDLtj8gaKbjkrG3R -BM2ildt6Uo8CgYBX7NDUli1SloH1XlsvD047S8FHaM7yl994F3J0UmDfpszcj1P4 -9pF64Mmq4/3Yt0li0mMuTb/Jgb3xrYgFJXkcecKahEVH3ropup+umsLAAIirUMQq -VSkFwJ0Qtnj/deDUwPNuaOX8cd65O5CFV6zIR9xBEDD8fBsP2ZLOzmefDQKBgQDF -m24vVthd/1cJdCgD+0VxNYXDHeIVXLFo1S0iLYCNLn3tjZlRQBKUXzZJe3ay0/rf -sNND7aSYHMYkTzydDJbc1PoNzxmyDUiTXpOWqyUExM/fbB1VUPE5h47AxqdZ2oGN -EtdgjpMZLmCIC2SkGsL+3NJok8UKHdpuErmmQIMk5QKBgQCgEWcYtLXC3YDYMFdI -UgcTebFqSs3mLYgub1xekW3IXR2yom4V5fQTLiF7Yfn2dpDW4IcMU0UJFYVsUlhK -aGtet4Vm5Nn8+Mghot5yAjqO9yAUaub7wgifKIe99tQKd8uZyCvJ0hhvmDDSfx4m -B/TEiFAO99yF49iSxEVSAS6pqQ== ------END PRIVATE KEY----- \ No newline at end of file diff --git a/examples/swarm/cloudflare.yml b/examples/swarm/cloudflare.yml new file mode 100644 index 0000000..c00ec74 --- /dev/null +++ b/examples/swarm/cloudflare.yml @@ -0,0 +1,122 @@ +# ============================================================================== +# EXAMPLE: Cloudflare IP Restoration (Swarm) +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Restoring original visitor IPs when behind Cloudflare CDN +# - Using Docker configs to manage Cloudflare IP lists +# - Service discovery in Swarm mode with plugins +# - Load balancing across multiple replicas +# +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init +# +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy +# +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Download Cloudflare IP ranges and create Docker config +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# echo "" >> cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# docker config create cloudflare_ips cloudflare_ips.lst +# rm cloudflare_ips.lst +# +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "myapp.example.com" /etc/hosts || echo "127.0.0.1 myapp.example.com" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash +# docker stack deploy -c cloudflare.yml webapp +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep webapp +# # Expected: webapp stack listed +# +# # Check service is running +# docker service ls | grep webapp_webapp +# # Expected: webapp_webapp with 4/4 replicas +# +# # Test the application +# curl -H "Host: myapp.example.com" http://localhost/ +# # Expected: 200 OK with "App Behind Cloudflare" +# +# # Check HAProxy config includes Cloudflare IPs +# docker exec $(docker ps -q -f name=easyhaproxy_haproxy) cat /etc/haproxy/haproxy.cfg | grep -A 5 "cloudflare" +# # Expected: ACL rules for Cloudflare IP ranges +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm webapp +# # To also remove the Cloudflare IPs config: +# # docker config rm cloudflare_ips +# ``` +# +# NOTE: This plugin is most useful when your site is actually behind Cloudflare CDN. +# The plugin uses the CF-Connecting-IP header to restore the original visitor IP. +# +# ============================================================================== + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: cloudflare_ips + target: /etc/haproxy/cloudflare_ips.lst + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Web application behind Cloudflare + webapp: + image: byjg/static-httpserver + environment: + TITLE: "App Behind Cloudflare" + deploy: + replicas: 4 + labels: + easyhaproxy.http.host: "myapp.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Enable Cloudflare plugin + easyhaproxy.http.plugins: "cloudflare" + + # Optional: Specify custom IP list path + # easyhaproxy.http.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + cloudflare_ips: + external: true diff --git a/examples/swarm/easyhaproxy.yml b/examples/swarm/easyhaproxy.yml index 3238896..0265571 100644 --- a/examples/swarm/easyhaproxy.yml +++ b/examples/swarm/easyhaproxy.yml @@ -1,10 +1,57 @@ -# To Install -# docker network create --driver overlay --attachable easyhaproxy +# ============================================================================== +# EXAMPLE: EasyHAProxy for Docker Swarm +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - EasyHAProxy running in Swarm mode with service discovery +# - HAProxy stats interface +# - Let's Encrypt/Certbot support +# - Shared overlay network for services +# +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init +# +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy +# +# # Edit this file and change: +# # Line 18: EASYHAPROXY_CERTBOT_EMAIL to your email +# ``` +# +# HOW TO START: +# ```bash # docker stack deploy -c easyhaproxy.yml easyhaproxy +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls +# # Expected: easyhaproxy stack listed +# +# # Check service is running +# docker service ls +# # Expected: easyhaproxy_haproxy with 1/1 replicas +# +# # View HAProxy stats +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# +# # Check logs +# docker service logs -f easyhaproxy_haproxy +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm easyhaproxy +# ``` +# +# ============================================================================== -version: "3" - services: haproxy: image: byjg/easy-haproxy:4.6.0 diff --git a/examples/swarm/ip-whitelist.yml b/examples/swarm/ip-whitelist.yml new file mode 100644 index 0000000..95c56ba --- /dev/null +++ b/examples/swarm/ip-whitelist.yml @@ -0,0 +1,113 @@ +# ============================================================================== +# EXAMPLE: IP Whitelist Plugin (Swarm) +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Restricting access to specific IP addresses/networks +# - IP-based access control for admin panels or sensitive services +# - Returning custom status codes for blocked IPs +# - Service discovery in Swarm mode with plugins +# +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init +# +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy +# +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "admin.example.com" /etc/hosts || echo "127.0.0.1 admin.example.com" | sudo tee -a /etc/hosts +# +# # IMPORTANT: Edit this file (ip-whitelist.yml) line 64 to add your actual IP addresses! +# # Get your current IP: curl ifconfig.me +# ``` +# +# HOW TO START: +# ```bash +# docker stack deploy -c ip-whitelist.yml admin +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep admin +# # Expected: admin stack listed +# +# # Check service is running +# docker service ls | grep admin_admin +# # Expected: admin_admin with 3/3 replicas +# +# # Test from allowed IP (assumes 127.0.0.1 or your IP is in the whitelist) +# curl -H "Host: admin.example.com" http://localhost/ +# # Expected: 200 OK with "Admin Panel - IP Restricted" +# +# # Test from blocked IP (using a different IP via proxy or VPN) +# # Expected: HTTP 403 Forbidden +# +# # View HAProxy stats to see IP whitelist rules +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm admin +# ``` +# +# ============================================================================== + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Admin panel with IP restrictions + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel - IP Restricted" + deploy: + replicas: 3 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Enable IP whitelist plugin + easyhaproxy.http.plugins: "ip_whitelist" + + # Allow specific IPs and networks + # UPDATE THIS with your actual office/VPN IPs! + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,198.51.100.0/24,10.0.0.0/8" + + # Status code to return for blocked IPs + easyhaproxy.http.plugin.ip_whitelist.status_code: "403" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true diff --git a/examples/swarm/jwt-validator.yml b/examples/swarm/jwt-validator.yml new file mode 100644 index 0000000..568e89b --- /dev/null +++ b/examples/swarm/jwt-validator.yml @@ -0,0 +1,132 @@ +# ============================================================================== +# EXAMPLE: JWT Validator Plugin (Swarm) +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - JWT token validation for API authentication +# - Using Docker configs to manage JWT public keys +# - Validating issuer, audience, and expiration claims +# - Service discovery in Swarm mode with plugins +# +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init +# +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy +# +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Generate JWT key pair (RS256 algorithm) +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# +# # Create Docker config with public key +# docker config create jwt_api_pubkey jwt_pubkey.pem +# +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "api.example.com" /etc/hosts || echo "127.0.0.1 api.example.com" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash +# docker stack deploy -c jwt-validator.yml api +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep api +# # Expected: api stack listed +# +# # Check service is running +# docker service ls | grep api_api +# # Expected: api_api with 5/5 replicas +# +# # Test without token (should fail) +# curl -H "Host: api.example.com" http://localhost/ +# # Expected: HTTP 401 with "Missing Authorization HTTP header" +# +# # Generate test JWT at https://jwt.io with: +# # - Algorithm: RS256 +# # - Payload: {"iss":"https://auth.example.com/","aud":"https://api.example.com","exp":9999999999} +# # - Use your jwt_private.pem content in "Verify Signature" section +# +# # Test with valid token (replace TOKEN with your JWT) +# TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +# curl -H "Host: api.example.com" -H "Authorization: Bearer $TOKEN" http://localhost/ +# # Expected: 200 OK with "Protected API - JWT Required" +# +# # Test with invalid/expired token +# curl -H "Host: api.example.com" -H "Authorization: Bearer invalid_token" http://localhost/ +# # Expected: HTTP 401 with error message +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm api +# # To also remove the JWT public key config and generated keys: +# # docker config rm jwt_api_pubkey +# # rm jwt_private.pem jwt_pubkey.pem +# ``` +# +# ============================================================================== + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: jwt_api_pubkey + target: /etc/haproxy/jwt_keys/api_pubkey.pem + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Protected API service + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API - JWT Required" + deploy: + replicas: 5 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Enable JWT validator plugin + easyhaproxy.http.plugins: "jwt_validator" + + # JWT validator configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + jwt_api_pubkey: + external: true diff --git a/examples/swarm/plugins-combined.yml b/examples/swarm/plugins-combined.yml new file mode 100644 index 0000000..fb8843c --- /dev/null +++ b/examples/swarm/plugins-combined.yml @@ -0,0 +1,200 @@ +# ============================================================================== +# EXAMPLE: Multiple Plugins Combined (Swarm) +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Using multiple plugins together for layered security +# - Three different security profiles for different service types: +# * Public website: Cloudflare IP restoration + path blocking +# * Protected API: JWT authentication + path blocking +# * Admin panel: Strict IP whitelist +# - Complex production-ready security configuration +# +# REQUIREMENTS (run these first): +# ```bash +# # Initialize Docker Swarm (if not already initialized) +# docker swarm init +# +# # Create overlay network (idempotent) +# docker network ls | grep -q easyhaproxy || docker network create --driver overlay --attachable easyhaproxy +# +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Generate JWT key pair (RS256 algorithm) +# openssl genrsa -out jwt_private.pem 2048 +# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem +# docker config create jwt_api_pubkey jwt_pubkey.pem +# +# # Download Cloudflare IP ranges and create Docker config +# curl https://www.cloudflare.com/ips-v4 > cloudflare_ips.lst +# echo "" >> cloudflare_ips.lst +# curl https://www.cloudflare.com/ips-v6 >> cloudflare_ips.lst +# docker config create cloudflare_ips cloudflare_ips.lst +# rm cloudflare_ips.lst jwt_private.pem jwt_pubkey.pem +# +# # Add to /etc/hosts for local testing (idempotent) +# grep -q "website.example.com" /etc/hosts || echo "127.0.0.1 website.example.com api.example.com admin.example.com" | sudo tee -a /etc/hosts +# +# # IMPORTANT: Edit this file (plugins-combined.yml) line 124 to add your actual IP! +# ``` +# +# HOW TO START: +# ```bash +# docker stack deploy -c plugins-combined.yml production +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check stack is deployed +# docker stack ls | grep production +# # Expected: production stack listed +# +# # Check all services are running +# docker service ls | grep production +# # Expected: 3 services (website, api, admin) with all replicas running +# +# # Test public website (Cloudflare + deny_pages) +# curl -H "Host: website.example.com" http://localhost/ +# # Expected: 200 OK with "Public Website" +# curl -H "Host: website.example.com" http://localhost/admin +# # Expected: HTTP 404 (blocked by deny_pages) +# +# # Test protected API (JWT + deny_pages) +# curl -H "Host: api.example.com" http://localhost/ +# # Expected: HTTP 401 with "Missing Authorization HTTP header" +# +# # Generate JWT at https://jwt.io (see jwt-validator.yml for details) +# TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." +# curl -H "Host: api.example.com" -H "Authorization: Bearer $TOKEN" http://localhost/ +# # Expected: 200 OK with "Protected API" +# curl -H "Host: api.example.com" -H "Authorization: Bearer $TOKEN" http://localhost/internal +# # Expected: HTTP 403 (blocked by deny_pages) +# +# # Test admin panel (IP whitelist) +# curl -H "Host: admin.example.com" http://localhost/ +# # Expected: 200 OK if your IP is whitelisted, 403 otherwise +# +# # View HAProxy stats to see all plugin configurations +# # URL: http://localhost:1936 +# # Username: admin +# # Password: password +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm production +# # To also remove Docker configs: +# # docker config rm cloudflare_ips jwt_api_pubkey +# ``` +# +# ============================================================================== + +version: "3.7" + +services: + haproxy: + image: byjg/easy-haproxy:4.6.0 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + configs: + - source: cloudflare_ips + target: /etc/haproxy/cloudflare_ips.lst + - source: jwt_api_pubkey + target: /etc/haproxy/jwt_keys/api_pubkey.pem + deploy: + replicas: 1 + placement: + constraints: + - node.role == manager + environment: + EASYHAPROXY_DISCOVER: swarm + EASYHAPROXY_SSL_MODE: "loose" + HAPROXY_CUSTOMERRORS: "true" + HAPROXY_USERNAME: admin + HAPROXY_PASSWORD: password + HAPROXY_STATS_PORT: 1936 + ports: + - "80:80/tcp" + - "443:443/tcp" + - "1936:1936/tcp" + networks: + - easyhaproxy + + # Public website with Cloudflare + path blocking + website: + image: byjg/static-httpserver + environment: + TITLE: "Public Website" + deploy: + replicas: 4 + labels: + easyhaproxy.http.host: "website.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # Cloudflare IP restoration + block sensitive paths + easyhaproxy.http.plugins: "cloudflare,deny_pages" + easyhaproxy.http.plugin.deny_pages.paths: "/admin,/wp-admin,/wp-login.php,/.env,/config" + easyhaproxy.http.plugin.deny_pages.status_code: "404" + networks: + - easyhaproxy + + # Protected API with JWT + path blocking + api: + image: byjg/static-httpserver + environment: + TITLE: "Protected API" + deploy: + replicas: 6 + labels: + easyhaproxy.http.host: "api.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # JWT validation + block internal endpoints + easyhaproxy.http.plugins: "jwt_validator,deny_pages" + + # JWT configuration + easyhaproxy.http.plugin.jwt_validator.algorithm: "RS256" + easyhaproxy.http.plugin.jwt_validator.issuer: "https://auth.example.com/" + easyhaproxy.http.plugin.jwt_validator.audience: "https://api.example.com" + easyhaproxy.http.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" + + # Block internal/debug paths + easyhaproxy.http.plugin.deny_pages.paths: "/internal,/debug,/metrics" + easyhaproxy.http.plugin.deny_pages.status_code: "403" + networks: + - easyhaproxy + + # Admin panel with strict IP whitelist + admin: + image: byjg/static-httpserver + environment: + TITLE: "Admin Panel" + deploy: + replicas: 2 + labels: + easyhaproxy.http.host: "admin.example.com" + easyhaproxy.http.port: "80" + easyhaproxy.http.localport: "8080" + + # IP whitelist only (strictest security) + easyhaproxy.http.plugins: "ip_whitelist" + + # Only allow office network + # UPDATE with your actual office/VPN IPs! + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "203.0.113.0/24,10.0.0.0/8" + easyhaproxy.http.plugin.ip_whitelist.status_code: "403" + networks: + - easyhaproxy + +networks: + easyhaproxy: + external: true + +configs: + cloudflare_ips: + external: true + jwt_api_pubkey: + external: true diff --git a/examples/swarm/portainer.yml b/examples/swarm/portainer.yml index d259988..2061a02 100644 --- a/examples/swarm/portainer.yml +++ b/examples/swarm/portainer.yml @@ -1,7 +1,46 @@ -# To install: +# ============================================================================== +# EXAMPLE: Portainer Behind EasyHAProxy (Swarm) +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Running Portainer management UI in Swarm mode +# - Service discovery with EasyHAProxy +# - Using persistent volumes for Portainer data +# +# REQUIREMENTS (run these first): +# ```bash +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Add to /etc/hosts (idempotent) +# grep -q "portainer.local" /etc/hosts || echo "127.0.0.1 portainer.local" | sudo tee -a /etc/hosts +# ``` +# +# HOW TO START: +# ```bash # docker stack deploy -c portainer.yml portainer - -version: "3" +# ``` +# +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check service is running +# docker service ls | grep portainer +# # Expected: portainer_portainer with 1/1 replicas +# +# # Access Portainer +# curl http://portainer.local +# # Or open in browser: http://portainer.local +# # First time: Create admin user +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm portainer +# # To also remove data volume: +# # docker volume rm portainer_portainer_data +# ``` +# +# ============================================================================== services: portainer: diff --git a/examples/swarm/services.yml b/examples/swarm/services.yml index ad16ce2..28c6b4f 100644 --- a/examples/swarm/services.yml +++ b/examples/swarm/services.yml @@ -1,24 +1,58 @@ -# To install: +# ============================================================================== +# EXAMPLE: Basic Swarm Services with SSL +# ============================================================================== +# +# WHAT THIS DEMONSTRATES: +# - Basic Swarm services with SSL configuration +# - HTTP to HTTPS redirect +# - Two services with different SSL setups (embedded cert vs SSL file) +# - Using deploy.labels for service discovery in Swarm +# +# REQUIREMENTS (run these first): +# ```bash +# # Ensure EasyHAProxy is deployed +# docker stack deploy -c easyhaproxy.yml easyhaproxy +# +# # Generate SSL certificates +# cd ../.. && ./examples/generate-keys.sh && cd examples/swarm +# +# # 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 +# ``` +# +# HOW TO START: +# ```bash # docker stack deploy -c services.yml services +# ``` # -# To test: +# HOW TO VERIFY IT'S WORKING: +# ```bash +# # Check services are running +# docker service ls | grep services +# # Expected: services_container and services_container2 with 1/1 replicas +# +# # Test HTTPS for host1.local # curl -k -H "Host: host1.local" https://127.0.0.1/ +# # Expected: 200 OK with hostname +# +# # Test HTTPS for host2.local # curl -k -H "Host: host2.local" https://127.0.0.1/ -# -# curl -I -H Host:host1.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Expected: 200 OK with hostname # -# curl -I -H Host:host2.local http://127.0.0.1 -# HTTP/1.1 301 Moved Permanently -# content-length: 0 -# location: https://host1.local/ +# # Test HTTP redirect +# curl -I -H "Host: host1.local" http://127.0.0.1 +# # Expected: HTTP/1.1 301 Moved Permanently, Location: https://host1.local/ # -# Test SSL: -# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local - -version: "3" +# # Verify SSL certificate +# openssl s_client -showcerts -connect 127.0.0.1:443 -servername host1.local < /dev/null +# ``` +# +# CLEAN UP: +# ```bash +# docker stack rm services +# ``` +# +# ============================================================================== services: container: diff --git a/helm/easyhaproxy/Chart.yaml b/helm/easyhaproxy/Chart.yaml index b5cc41c..b5ed224 100644 --- a/helm/easyhaproxy/Chart.yaml +++ b/helm/easyhaproxy/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.1.9 +version: 1.0.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/src/easymapping/__init__.py b/src/easymapping/__init__.py index bb1c833..77930a4 100644 --- a/src/easymapping/__init__.py +++ b/src/easymapping/__init__.py @@ -1,8 +1,10 @@ import base64 import json +import os import re from jinja2 import Environment, FileSystemLoader +from functions import loggerEasyHaproxy class DockerLabelHandler: @@ -31,7 +33,16 @@ class DockerLabelHandler: def get_json(self, label, default_value={}): if self.has_label(label): - return json.loads(self.__data[label]) + value = self.__data[label] + if not value: # Handle empty strings + return default_value + try: + return json.loads(value) + except json.JSONDecodeError as e: + loggerEasyHaproxy.error( + f"Invalid JSON in label '{label}': {value}. Error: {e}. Using default value." + ) + return default_value return default_value def set_data(self, data): @@ -54,19 +65,62 @@ class HaproxyConfigGenerator: self.serving_hosts = [] self.certs = {} + # Initialize plugin system + try: + from plugins import PluginManager + self.plugin_manager = PluginManager( + plugins_dir="/etc/haproxy/plugins", + abort_on_error=self.mapping.get("plugins", {}).get("abort_on_error", False) + ) + self.plugin_manager.load_plugins() + self.plugin_manager.configure_plugins(self.mapping.get("plugins", {})) + self.global_plugin_configs = [] + except Exception as e: + # If plugin system fails to initialize, log but continue + loggerEasyHaproxy.warning(f"Failed to initialize plugin system: {e}") + self.plugin_manager = None + self.global_plugin_configs = [] + def generate(self, container_metadata={}): self.mapping.setdefault("easymapping", []) if container_metadata != {}: self.mapping["easymapping"] = self.parse(container_metadata) - file_loader = FileSystemLoader('templates') + # Execute global plugins + if self.plugin_manager: + try: + from plugins import PluginContext + global_context = PluginContext( + parsed_object=container_metadata, + easymapping=self.mapping.get("easymapping", []), + container_env=self.mapping, + domain=None, + port=None, + host_config=None + ) + + # Get enabled plugins from config + enabled_list = self.mapping.get("plugins", {}).get("enabled", []) + # If enabled list contains only empty string, treat as no plugins enabled + if enabled_list and len(enabled_list) > 0 and enabled_list[0] == "": + enabled_list = [] + + global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list) + # 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) + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to execute global plugins: {e}") + + templates_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'templates') + file_loader = FileSystemLoader(templates_dir) env = Environment(loader=file_loader) env.trim_blocks = True env.lstrip_blocks = True env.rstrip_blocks = True template = env.get_template('haproxy.cfg.j2') - return template.render(data=self.mapping) + return template.render(data=self.mapping, global_plugin_configs=self.global_plugin_configs) def parse(self, container_metadata): easymapping = dict() @@ -131,13 +185,33 @@ class HaproxyConfigGenerator: "" ) + # Protocol for backend server communication (e.g., fcgi, h2) + proto = self.label.get( + self.label.create([definition, "proto"]), + "" + ) + + # Unix socket path (alternative to host:port) + socket_path = self.label.get( + self.label.create([definition, "socket"]), + "" + ) + for hostname in sorted(d[host_label].split(",")): hostname = hostname.strip() self.serving_hosts.append("%s:%s" % (hostname, port)) easymapping[port]["hosts"].setdefault(hostname, {}) easymapping[port]["hosts"][hostname].setdefault("containers", []) easymapping[port]["hosts"][hostname].setdefault("certbot", False) - easymapping[port]["hosts"][hostname]["containers"] += ["{}:{}".format(container, ct_port)] + easymapping[port]["hosts"][hostname].setdefault("proto", proto) + + # Determine server address: Unix socket or TCP host:port + if socket_path: + server_address = socket_path + else: + server_address = "{}:{}".format(container, ct_port) + + easymapping[port]["hosts"][hostname]["containers"] += [server_address] easymapping[port]["hosts"][hostname]["certbot"] = certbot easymapping[port]["hosts"][hostname]["redirect_ssl"] = self.label.get_bool( self.label.create([definition, "redirect_ssl"]) @@ -151,6 +225,68 @@ class HaproxyConfigGenerator: self.label.create([definition, "redirect"]) ) + # Execute domain plugins for this host + if self.plugin_manager: + try: + from plugins import PluginContext + + domain_context = PluginContext( + parsed_object=container_metadata, + easymapping=easymapping, + container_env=self.mapping, + domain=hostname, + port=port, + host_config=easymapping[port]["hosts"][hostname] + ) + + # Check if plugins are enabled for this domain (from labels) + enabled_plugins = [] + if self.label.has_label(self.label.create([definition, "plugins"])): + enabled_plugins = self.label.get( + self.label.create([definition, "plugins"]), + "" + ).split(",") + enabled_plugins = [p.strip() for p in enabled_plugins if p.strip()] + + # Extract plugin configurations from labels + # Format: easyhaproxy.http.plugin.PLUGIN_NAME.CONFIG_KEY + plugin_configs = {} + for plugin_name in enabled_plugins: + plugin_configs[plugin_name] = {} + # Look for all labels matching easyhaproxy.{definition}.plugin.{plugin_name}.* + plugin_label_prefix = self.label.create([definition, "plugin", plugin_name]) + for label_key in d.keys(): + if label_key.startswith(plugin_label_prefix + "."): + # Extract config key (everything after plugin_label_prefix + ".") + config_key = label_key[len(plugin_label_prefix) + 1:] + plugin_configs[plugin_name][config_key] = d[label_key] + + # Configure plugins with label-specific configs before execution + for plugin_name, config in plugin_configs.items(): + if plugin_name in self.plugin_manager.plugins: + self.plugin_manager.plugins[plugin_name].configure(config) + + domain_results = self.plugin_manager.execute_domain_plugins( + domain_context, + enabled_list=enabled_plugins + ) + + # Store domain plugin configs for this host + easymapping[port]["hosts"][hostname]["plugin_configs"] = [ + r.haproxy_config for r in domain_results if r.haproxy_config + ] + + # Extract fcgi-app definitions from metadata and add to global configs + for result in domain_results: + 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"]) + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}") + easymapping[port]["hosts"][hostname]["plugin_configs"] = [] + else: + easymapping[port]["hosts"][hostname]["plugin_configs"] = [] + if certbot or clone_to_ssl: if "443" not in easymapping: easymapping["443"] = { diff --git a/src/functions/__init__.py b/src/functions/__init__.py index c6e8bcf..979eb42 100644 --- a/src/functions/__init__.py +++ b/src/functions/__init__.py @@ -88,6 +88,23 @@ class ContainerEnv: os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"] + # Plugin configuration + env_vars["plugins"] = { + "abort_on_error": os.getenv("EASYHAPROXY_PLUGINS_ABORT_ON_ERROR", "false").lower() == "true", + "enabled": os.getenv("EASYHAPROXY_PLUGINS_ENABLED", "").split(",") if os.getenv("EASYHAPROXY_PLUGINS_ENABLED") else [], + "config": {} # Individual plugin configs from env vars + } + + # Parse individual plugin configs (e.g., EASYHAPROXY_PLUGIN_CLOUDFLARE_*) + for key, value in os.environ.items(): + if key.startswith("EASYHAPROXY_PLUGIN_"): + parts = key.split("_", 3) # ['EASYHAPROXY', 'PLUGIN', 'NAME', 'KEY'] + if len(parts) >= 4: + plugin_name = parts[2].lower() + config_key = "_".join(parts[3:]).lower() + env_vars["plugins"]["config"].setdefault(plugin_name, {}) + env_vars["plugins"]["config"][plugin_name][config_key] = value + return env_vars diff --git a/src/main.py b/src/main.py index 30412a3..7af2bbf 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,4 @@ import os -import logging from deepdiff import DeepDiff diff --git a/src/plugins/__init__.py b/src/plugins/__init__.py new file mode 100644 index 0000000..e822d92 --- /dev/null +++ b/src/plugins/__init__.py @@ -0,0 +1,250 @@ +import os +import importlib.util +import sys +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional, Dict, Any, List +from functions import loggerEasyHaproxy + + +class PluginType(Enum): + """Plugin execution types""" + GLOBAL = "global" # Execute once per discovery cycle + DOMAIN = "domain" # Execute per domain/host + + +@dataclass +class PluginContext: + """Container for all plugin execution data""" + parsed_object: dict # {IP: labels} from discovery + easymapping: list # Current HAProxy mapping structure + container_env: dict # Environment configuration + domain: Optional[str] = None # Domain name (for DOMAIN plugins) + port: Optional[str] = None # Port (for DOMAIN plugins) + host_config: Optional[dict] = None # Domain-specific config + + +@dataclass +class PluginResult: + """Plugin execution result""" + haproxy_config: str = "" # HAProxy config snippet to inject + modified_easymapping: Optional[list] = None # Modified easymapping structure + metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging + + +class PluginInterface(ABC): + """Base class all plugins must inherit""" + + @property + @abstractmethod + def name(self) -> str: + """Return the unique plugin name""" + pass + + @property + @abstractmethod + def plugin_type(self) -> PluginType: + """Return the plugin type (GLOBAL or DOMAIN)""" + pass + + @abstractmethod + def configure(self, config: dict) -> None: + """ + Configure the plugin with settings from YAML/env/labels + + Args: + config: Dictionary with plugin-specific configuration + """ + pass + + @abstractmethod + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin logic and return result + + Args: + context: PluginContext with all necessary data + + Returns: + PluginResult with HAProxy config snippets and/or modified data + """ + pass + + +class PluginManager: + """Manages plugin loading, configuration, and execution""" + + def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False): + """ + Initialize the plugin manager + + Args: + plugins_dir: Directory containing plugin files + abort_on_error: If True, abort on plugin errors; if False, log and continue + """ + self.plugins_dir = plugins_dir + self.abort_on_error = abort_on_error + self.plugins: Dict[str, PluginInterface] = {} + self.global_plugins: List[PluginInterface] = [] + self.domain_plugins: List[PluginInterface] = [] + self.logger = loggerEasyHaproxy + + def load_plugins(self) -> None: + """ + Discover and load plugins from the plugins directory + Loads both builtin plugins and external plugins + """ + # Load builtin plugins first + builtin_dir = os.path.join(os.path.dirname(__file__), "builtin") + self._load_plugins_from_directory(builtin_dir, "builtin") + + # Load external plugins from /etc/haproxy/plugins + if os.path.exists(self.plugins_dir): + self._load_plugins_from_directory(self.plugins_dir, "external") + else: + self.logger.info(f"Plugin directory {self.plugins_dir} does not exist, skipping external plugins") + + def _load_plugins_from_directory(self, directory: str, source: str) -> None: + """ + Load plugins from a specific directory + + Args: + directory: Path to directory containing plugins + source: Source identifier ("builtin" or "external") + """ + if not os.path.exists(directory): + return + + for filename in os.listdir(directory): + if filename.endswith(".py") and not filename.startswith("__"): + filepath = os.path.join(directory, filename) + module_name = f"plugins.{source}.{filename[:-3]}" + + try: + # Load module from file + spec = importlib.util.spec_from_file_location(module_name, filepath) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + # Find plugin classes in module + for item_name in dir(module): + item = getattr(module, item_name) + if (isinstance(item, type) and + issubclass(item, PluginInterface) and + item is not PluginInterface): + # Instantiate plugin + plugin = item() + self.plugins[plugin.name] = plugin + + # Categorize by type + if plugin.plugin_type == PluginType.GLOBAL: + self.global_plugins.append(plugin) + elif plugin.plugin_type == PluginType.DOMAIN: + self.domain_plugins.append(plugin) + + self.logger.debug(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})") + + except Exception as e: + self._handle_error(f"Failed to load plugin from {filepath}: {str(e)}") + + def configure_plugins(self, plugins_config: dict) -> None: + """ + Configure all loaded plugins with their settings + + Args: + plugins_config: Plugin configuration from YAML/env + Format: {"plugin_name": {"key": "value"}, ...} + """ + for plugin_name, plugin in self.plugins.items(): + try: + # Get plugin-specific config + plugin_cfg = plugins_config.get(plugin_name, {}) + + # Also check "config" sub-key for env var configs + if "config" in plugins_config and plugin_name in plugins_config["config"]: + plugin_cfg.update(plugins_config["config"][plugin_name]) + + # Configure plugin + plugin.configure(plugin_cfg) + self.logger.debug(f"Configured plugin: {plugin_name} with config: {plugin_cfg}") + + except Exception as e: + self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(e)}") + + def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """ + Execute all global plugins + + Args: + context: PluginContext with execution data + enabled_list: Optional list of plugin names to execute. If None, execute all. + + Returns: + List of PluginResult from each plugin + """ + results = [] + + for plugin in self.global_plugins: + # Check if plugin is in enabled list (if provided) + if enabled_list is not None and plugin.name not in enabled_list: + continue + + try: + self.logger.debug(f"Executing global plugin: {plugin.name}") + result = plugin.process(context) + results.append(result) + + if result.metadata: + self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}") + + except Exception as e: + self._handle_error(f"Global plugin '{plugin.name}' failed: {str(e)}") + + return results + + def execute_domain_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]: + """ + Execute all domain plugins for a specific domain + + Args: + context: PluginContext with domain-specific data + enabled_list: Optional list of plugin names to execute. If None, execute all. + + Returns: + List of PluginResult from each plugin + """ + results = [] + + for plugin in self.domain_plugins: + # Check if plugin is in enabled list (if provided) + if enabled_list is not None and plugin.name not in enabled_list: + continue + + try: + self.logger.debug(f"Executing domain plugin: {plugin.name} for domain: {context.domain}") + result = plugin.process(context) + results.append(result) + + if result.metadata: + self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}") + + except Exception as e: + self._handle_error(f"Domain plugin '{plugin.name}' failed for domain '{context.domain}': {str(e)}") + + return results + + def _handle_error(self, message: str) -> None: + """ + Handle plugin errors according to abort_on_error setting + + Args: + message: Error message to log + """ + if self.abort_on_error: + self.logger.error(message) + raise RuntimeError(message) + else: + self.logger.warning(message) diff --git a/src/plugins/builtin/__init__.py b/src/plugins/builtin/__init__.py new file mode 100644 index 0000000..3afacbe --- /dev/null +++ b/src/plugins/builtin/__init__.py @@ -0,0 +1 @@ +# Built-in plugins for EasyHAProxy diff --git a/src/plugins/builtin/cleanup.py b/src/plugins/builtin/cleanup.py new file mode 100644 index 0000000..45ef13d --- /dev/null +++ b/src/plugins/builtin/cleanup.py @@ -0,0 +1,124 @@ +""" +Cleanup Plugin for EasyHAProxy + +This plugin performs cleanup tasks during each discovery cycle. +It runs as a GLOBAL plugin (once per cycle). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - max_idle_time: Maximum idle time before cleanup in seconds (default: 300) + - cleanup_temp_files: Clean up temporary files (default: true) + +Example YAML config: + plugins: + cleanup: + enabled: true + max_idle_time: 300 + cleanup_temp_files: true + +Example Environment Variable: + EASYHAPROXY_PLUGINS_ENABLED=cleanup + EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 +""" + +import os +import sys +import glob +import time + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class CleanupPlugin(PluginInterface): + """Plugin to perform cleanup tasks during discovery cycle""" + + def __init__(self): + self.enabled = True + self.max_idle_time = 300 # 5 minutes + self.cleanup_temp_files = True + + @property + def name(self) -> str: + return "cleanup" + + @property + def plugin_type(self) -> PluginType: + return PluginType.GLOBAL + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - max_idle_time: Maximum idle time in seconds + - cleanup_temp_files: Whether to clean up temp files + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "max_idle_time" in config: + try: + self.max_idle_time = int(config["max_idle_time"]) + except ValueError: + loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") + + if "cleanup_temp_files" in config: + self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Perform cleanup tasks + + Args: + context: Plugin execution context + + Returns: + PluginResult with metadata about cleanup actions + """ + if not self.enabled: + return PluginResult() + + cleanup_actions = [] + + # Cleanup temporary files + if self.cleanup_temp_files: + temp_dirs = ["/tmp", "/var/tmp"] + current_time = time.time() + + for temp_dir in temp_dirs: + if not os.path.exists(temp_dir): + continue + + try: + # Find old EasyHAProxy temp files + pattern = os.path.join(temp_dir, "easyhaproxy_*") + for filepath in glob.glob(pattern): + try: + file_age = current_time - os.path.getmtime(filepath) + if file_age > self.max_idle_time: + os.remove(filepath) + cleanup_actions.append(f"Removed old temp file: {filepath}") + loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") + except Exception as e: + loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") + + # Log cleanup summary + if cleanup_actions: + loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") + + return PluginResult( + haproxy_config="", # No HAProxy config needed for cleanup + modified_easymapping=None, + metadata={ + "actions_performed": len(cleanup_actions), + "actions": cleanup_actions + } + ) diff --git a/src/plugins/builtin/cloudflare.py b/src/plugins/builtin/cloudflare.py new file mode 100644 index 0000000..f8cb0be --- /dev/null +++ b/src/plugins/builtin/cloudflare.py @@ -0,0 +1,148 @@ +""" +Cloudflare Plugin for EasyHAProxy + +This plugin restores the original visitor IP address from Cloudflare's +CF-Connecting-IP header when requests come through Cloudflare's CDN. + +The plugin includes built-in Cloudflare IP ranges that are automatically +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) + +Example YAML config: + plugins: + cloudflare: + enabled: true + ip_list_path: /etc/haproxy/cloudflare_ips.lst + use_builtin_ips: true + +Example Container Label: + easyhaproxy.http.plugins: "cloudflare" + +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 +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class CloudflarePlugin(PluginInterface): + """Plugin to restore original visitor IP from Cloudflare""" + + # Current Cloudflare IP ranges (IPv4 and IPv6) + # Source: https://www.cloudflare.com/ips/ + CLOUDFLARE_IPS = [ + # IPv4 + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + # IPv6 + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + ] + + def __init__(self): + self.ip_list_path = "/etc/haproxy/cloudflare_ips.lst" + self.enabled = True + self.use_builtin_ips = True + + @property + def name(self) -> str: + return "cloudflare" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - 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) + """ + if "ip_list_path" in config: + self.ip_list_path = config["ip_list_path"] + + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "use_builtin_ips" in config: + self.use_builtin_ips = str(config["use_builtin_ips"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to restore original IP from Cloudflare + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled: + return PluginResult() + + # Write built-in Cloudflare IPs to file if using built-in IPs + if self.use_builtin_ips: + try: + # Create directory if it doesn't exist + ip_list_dir = os.path.dirname(self.ip_list_path) + if ip_list_dir and not os.path.exists(ip_list_dir): + os.makedirs(ip_list_dir, exist_ok=True) + + # Write Cloudflare IPs to file + with open(self.ip_list_path, 'w') as f: + for ip_range in self.CLOUDFLARE_IPS: + f.write(f"{ip_range}\n") + + loggerEasyHaproxy.info(f"Cloudflare plugin: Written {len(self.CLOUDFLARE_IPS)} IP ranges to {self.ip_list_path}") + except Exception as e: + loggerEasyHaproxy.warning(f"Cloudflare plugin: Failed to write IP list to {self.ip_list_path}: {e}") + + # Generate HAProxy config snippet + 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""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "ip_list_path": self.ip_list_path, + "use_builtin_ips": self.use_builtin_ips, + "ip_count": len(self.CLOUDFLARE_IPS) if self.use_builtin_ips else None + } + ) diff --git a/src/plugins/builtin/deny_pages.py b/src/plugins/builtin/deny_pages.py new file mode 100644 index 0000000..5335751 --- /dev/null +++ b/src/plugins/builtin/deny_pages.py @@ -0,0 +1,106 @@ +""" +Deny Pages Plugin for EasyHAProxy + +This plugin blocks access to specific paths for a domain. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - paths: Comma-separated list of paths to deny (e.g., "/admin,/private") + - status_code: HTTP status code to return (default: 403) + +Example YAML config: + plugins: + deny_pages: + enabled: true + paths: "/admin,/private,/internal" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "deny_pages" + easyhaproxy.http.plugin.deny_pages.paths: "/admin,/private" + +HAProxy Config Generated: + # Deny Pages - Block specific paths + acl denied_path path_beg /admin /private + http-request deny deny_status 403 if denied_path +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class DenyPagesPlugin(PluginInterface): + """Plugin to deny access to specific paths""" + + def __init__(self): + self.enabled = True + self.paths = [] + self.status_code = 403 + + @property + def name(self) -> str: + return "deny_pages" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - paths: Comma-separated list of paths to deny + - status_code: HTTP status code to return + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "paths" in config: + paths_str = str(config["paths"]) + self.paths = [p.strip() for p in paths_str.split(",") if p.strip()] + + if "status_code" in config: + try: + self.status_code = int(config["status_code"]) + except ValueError: + self.status_code = 403 + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to deny specific paths + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.paths: + return PluginResult() + + # Create path list for ACL + paths_str = " ".join(self.paths) + + # Generate HAProxy config snippet + haproxy_config = f"""# Deny Pages - Block specific paths +acl denied_path path_beg {paths_str} +http-request deny deny_status {self.status_code} if denied_path""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "blocked_paths": self.paths, + "status_code": self.status_code + } + ) diff --git a/src/plugins/builtin/fastcgi.py b/src/plugins/builtin/fastcgi.py new file mode 100644 index 0000000..c50206f --- /dev/null +++ b/src/plugins/builtin/fastcgi.py @@ -0,0 +1,155 @@ +""" +FastCGI Plugin for EasyHAProxy + +This plugin generates HAProxy fcgi-app configuration for PHP-FPM and other FastCGI applications. +It runs as a DOMAIN plugin (once per domain). + +The plugin creates: + 1. A top-level fcgi-app section with CGI parameter definitions + 2. A use-fcgi-app directive in the backend + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - document_root: Document root path (default: /var/www/html) + - script_filename: Pattern for SCRIPT_FILENAME (default: %[path]) + - index_file: Default index file (default: index.php) + - path_info: Enable PATH_INFO support (default: true) + - custom_params: Dictionary of custom FastCGI parameters (optional) + +Example YAML config: + plugins: + fastcgi: + enabled: true + document_root: /var/www/html + index_file: index.php + path_info: true + +Example Container Label: + easyhaproxy.http.plugins: "fastcgi" + easyhaproxy.http.plugin.fastcgi.document_root: /var/www/myapp + easyhaproxy.http.plugin.fastcgi.index_file: index.php + easyhaproxy.http.plugin.fastcgi.path_info: true + +Example Kubernetes Annotation: + easyhaproxy.plugins: "fastcgi" + easyhaproxy.plugin.fastcgi.document_root: /var/www/myapp + easyhaproxy.plugin.fastcgi.index_file: index.php +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class FastcgiPlugin(PluginInterface): + """Plugin to configure FastCGI parameters for PHP-FPM""" + + def __init__(self): + self.enabled = True + self.document_root = "/var/www/html" + self.script_filename = "%[path]" + self.index_file = "index.php" + self.path_info = True + self.custom_params = {} + + @property + def name(self) -> str: + return "fastcgi" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - document_root: Document root path + - script_filename: Pattern for SCRIPT_FILENAME + - index_file: Default index file + - path_info: Enable PATH_INFO support + - custom_params: Dictionary of custom FastCGI parameters + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "document_root" in config: + self.document_root = config["document_root"] + + if "script_filename" in config: + self.script_filename = config["script_filename"] + + if "index_file" in config: + self.index_file = config["index_file"] + + if "path_info" in config: + self.path_info = str(config["path_info"]).lower() in ["true", "1", "yes"] + + if "custom_params" in config: + self.custom_params = config["custom_params"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Process the plugin and generate FastCGI configuration + + Args: + context: Plugin execution context + + Returns: + PluginResult with HAProxy FastCGI configuration + """ + if not self.enabled: + return PluginResult() + + # Generate a unique fcgi-app name based on the domain + # Replace dots and colons with underscores for valid HAProxy identifier + domain_safe = context.domain.replace(".", "_").replace(":", "_") + fcgi_app_name = f"fcgi_{domain_safe}" + + # Generate the use-fcgi-app directive for the backend + backend_config = f"use-fcgi-app {fcgi_app_name}" + + # Generate the fcgi-app section (to be inserted at top level) + fcgi_app_lines = [f"fcgi-app {fcgi_app_name}"] + fcgi_app_lines.append(f" docroot {self.document_root}") + fcgi_app_lines.append(f" index {self.index_file}") + + # PATH_INFO support + if self.path_info: + fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$") + + # Set SCRIPT_FILENAME if customized + if self.script_filename and self.script_filename != "%[path]": + fcgi_app_lines.append(f" set-param SCRIPT_FILENAME {self.script_filename}") + + # Custom parameters + if self.custom_params: + for param_name, param_value in self.custom_params.items(): + fcgi_app_lines.append(f" set-param {param_name.upper()} {param_value}") + + fcgi_app_definition = "\n".join(fcgi_app_lines) + + # Build metadata - store fcgi_app_definition to be extracted and added to global configs + metadata = { + "domain": context.domain, + "fcgi_app_name": fcgi_app_name, + "fcgi_app_definition": fcgi_app_definition, # For top-level injection + "document_root": self.document_root, + "index_file": self.index_file, + "path_info": self.path_info, + "custom_params_count": len(self.custom_params) + } + + return PluginResult( + haproxy_config=backend_config, # use-fcgi-app directive for the backend + modified_easymapping=None, + metadata=metadata + ) diff --git a/src/plugins/builtin/ip_whitelist.py b/src/plugins/builtin/ip_whitelist.py new file mode 100644 index 0000000..b54265c --- /dev/null +++ b/src/plugins/builtin/ip_whitelist.py @@ -0,0 +1,107 @@ +""" +IP Whitelist Plugin for EasyHAProxy + +This plugin restricts access to a domain to only specific IP addresses or CIDR ranges. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - allowed_ips: Comma-separated list of IPs/CIDR ranges to allow + - status_code: HTTP status code to return for blocked IPs (default: 403) + +Example YAML config: + plugins: + ip_whitelist: + enabled: true + allowed_ips: "192.168.1.0/24,10.0.0.1,172.16.0.0/16" + status_code: 403 + +Example Container Label: + easyhaproxy.http.plugins: "ip_whitelist" + easyhaproxy.http.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.1" + easyhaproxy.http.plugin.ip_whitelist.status_code: 403 + +HAProxy Config Generated: + # IP Whitelist - Only allow specific IPs + acl whitelisted_ip src 192.168.1.0/24 10.0.0.1 + http-request deny deny_status 403 if !whitelisted_ip +""" + +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult + + +class IpWhitelistPlugin(PluginInterface): + """Plugin to restrict access to specific IP addresses""" + + def __init__(self): + self.enabled = True + self.allowed_ips = [] + self.status_code = 403 + + @property + def name(self) -> str: + return "ip_whitelist" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - allowed_ips: Comma-separated list of IPs/CIDR ranges + - status_code: HTTP status code to return for denied requests + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "allowed_ips" in config: + ips_str = str(config["allowed_ips"]) + self.allowed_ips = [ip.strip() for ip in ips_str.split(",") if ip.strip()] + + if "status_code" in config: + try: + self.status_code = int(config["status_code"]) + except ValueError: + self.status_code = 403 + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to whitelist specific IPs + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled or not self.allowed_ips: + return PluginResult() + + # Create space-separated list of IPs for ACL + ips_str = " ".join(self.allowed_ips) + + # Generate HAProxy config snippet + haproxy_config = f"""# IP Whitelist - Only allow specific IPs +acl whitelisted_ip src {ips_str} +http-request deny deny_status {self.status_code} if !whitelisted_ip""" + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata={ + "domain": context.domain, + "allowed_ips": self.allowed_ips, + "status_code": self.status_code + } + ) diff --git a/src/plugins/builtin/jwt_validator.py b/src/plugins/builtin/jwt_validator.py new file mode 100644 index 0000000..5a7b4af --- /dev/null +++ b/src/plugins/builtin/jwt_validator.py @@ -0,0 +1,282 @@ +""" +JWT Validator Plugin for EasyHAProxy + +This plugin validates JWT tokens using HAProxy's built-in JWT functionality. +It runs as a DOMAIN plugin (once per domain). + +Configuration: + - enabled: Enable/disable the plugin (default: true) + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) + - audience: Expected JWT audience (optional, set to "none"/"null" to skip validation) + - pubkey_path: Path to public key file (required if pubkey not provided) + - pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) + - paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) + - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation + - allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header + +Path Validation Logic: + - No paths configured: ALL requests to the domain require JWT validation (default behavior) + - Paths configured + only_paths=false: Only specified paths require JWT validation, others pass through + - Paths configured + only_paths=true: Only specified paths are accessible (with JWT), all others are denied + +Anonymous Access Logic: + - allow_anonymous=false (default): Requests without Authorization header are denied + - allow_anonymous=true: Requests without Authorization header are allowed, but JWTs are validated if present + +Example YAML config: + plugins: + jwt_validator: + enabled: true + algorithm: RS256 + issuer: https://myaccount.auth0.com/ + audience: https://api.mywebsite.com + pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem + paths: + - /api/admin + - /api/sensitive + only_paths: false + +Example Container Label: + easyhaproxy.http.plugins: "jwt_validator" + easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 + easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ + easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com + easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem + easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive + easyhaproxy.http.plugin.jwt_validator.only_paths: true + +HAProxy Config Generated: + # JWT Validator - Validate JWT tokens + http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } + + # Extract JWT header and payload + http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') + http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss') + http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud') + http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int') + + # Validate JWT + http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } + http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } + http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } + http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } + + # Validate expiration + http-request set-var(txn.now) date() + http-request deny content-type 'text/html' string 'JWT has expired' if { var(txn.exp),sub(txn.now) -m int lt 0 } +""" + +import base64 +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import loggerEasyHaproxy + + +class JwtValidatorPlugin(PluginInterface): + """Plugin to validate JWT tokens""" + + def __init__(self): + self.enabled = True + self.algorithm = "RS256" + self.issuer = None # Optional + self.audience = None # Optional + self.pubkey_path = None # Path to public key file + self.pubkey = None # Public key content (alternative to pubkey_path) + self.paths = [] # List of paths that require JWT validation + self.only_paths = False # If true, only specified paths are accessible + self.allow_anonymous = False # If true, allow requests without Authorization header + + @property + def name(self) -> str: + return "jwt_validator" + + @property + def plugin_type(self) -> PluginType: + return PluginType.DOMAIN + + def configure(self, config: dict) -> None: + """ + Configure the plugin + + Args: + config: Dictionary with configuration options + - enabled: Whether plugin is enabled + - algorithm: JWT signing algorithm (default: RS256) + - issuer: Expected JWT issuer (optional) + - audience: Expected JWT audience (optional) + - pubkey_path: Path to public key file + - pubkey: Public key content as base64-encoded string + - paths: List of paths that require JWT validation (optional) + - only_paths: If true, only specified paths are accessible (default: false) + - allow_anonymous: If true, allow requests without Authorization header (default: false) + """ + if "enabled" in config: + self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] + + if "algorithm" in config: + self.algorithm = config["algorithm"] + + # Parse issuer (optional - if not set, issuer validation is skipped) + if "issuer" in config: + issuer = str(config["issuer"]).strip() + if issuer: # Only set if not empty + self.issuer = issuer + + # Parse audience (optional - if not set, audience validation is skipped) + if "audience" in config: + audience = str(config["audience"]).strip() + if audience: # Only set if not empty + self.audience = audience + + # Public key configuration + if "pubkey_path" in config: + self.pubkey_path = config["pubkey_path"] + + if "pubkey" in config: + # Decode from base64 (consistent with sslcert parameter) + self.pubkey = base64.b64decode(config["pubkey"]).decode('ascii') + + # Path configuration + if "paths" in config: + paths_config = config["paths"] + if isinstance(paths_config, list): + self.paths = [str(p).strip() for p in paths_config if str(p).strip()] + elif isinstance(paths_config, str): + # Support comma-separated paths for container labels + self.paths = [p.strip() for p in paths_config.split(",") if p.strip()] + else: + self.paths = [] + + if "only_paths" in config: + self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] + + if "allow_anonymous" in config: + self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"] + + def process(self, context: PluginContext) -> PluginResult: + """ + Generate HAProxy config to validate JWT tokens + + Args: + context: Plugin execution context with domain information + + Returns: + PluginResult with HAProxy configuration snippet + """ + if not self.enabled: + return PluginResult() + + # Determine public key file path + if self.pubkey_path: + pubkey_file = self.pubkey_path + elif self.pubkey: + # Generate path for pubkey based on domain + domain_safe = context.domain.replace(".", "_").replace(":", "_") + pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" + else: + loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") + return PluginResult() + + # Build HAProxy configuration + lines = ["# JWT Validator - Validate JWT tokens"] + + # Determine path condition suffix + path_condition = "" + if self.paths: + # Define ACL for protected paths + lines.append("") + lines.append("# Define paths that require JWT validation") + for path in self.paths: + lines.append(f"acl jwt_protected_path path_beg {path}") + lines.append("") + + if self.only_paths: + # Deny all paths that are not in the protected list + lines.append("# Deny access to paths not in the protected list") + lines.append("http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path") + lines.append("") + # All remaining requests are on protected paths, no condition needed + path_condition = "" + else: + # Only validate JWT on protected paths + path_condition = " if jwt_protected_path" + + # Check for Authorization header + if not self.allow_anonymous: + # Require Authorization header (default behavior) + lines.append(f"http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless {{ req.hdr(authorization) -m found }}{path_condition}") + jwt_condition = path_condition + else: + # Allow anonymous access - only validate JWT if Authorization header is present + lines.append("") + lines.append("# Allow anonymous access - validate JWT only if Authorization header is present") + if path_condition: + # Combine path condition with Authorization header check + jwt_condition = f"{path_condition} if {{ req.hdr(authorization) -m found }}" + else: + jwt_condition = " if { req.hdr(authorization) -m found }" + + # Extract JWT parts + lines.append("") + lines.append("# Extract JWT header and payload") + lines.append(f"http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg'){jwt_condition}") + lines.append(f"http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss'){jwt_condition}") + lines.append(f"http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud'){jwt_condition}") + lines.append(f"http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int'){jwt_condition}") + + # Validate JWT + lines.append("") + lines.append("# Validate JWT") + lines.append(f"http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless {{ var(txn.alg) -m str {self.algorithm} }}{jwt_condition}") + + # Validate issuer (if configured) + if self.issuer: + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT issuer' unless {{ var(txn.iss) -m str {self.issuer} }}{jwt_condition}") + + # Validate audience (if configured) + if self.audience: + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT audience' unless {{ var(txn.aud) -m str {self.audience} }}{jwt_condition}") + + # Validate signature + lines.append(f"http-request deny content-type 'text/html' string 'Invalid JWT signature' unless {{ http_auth_bearer,jwt_verify(txn.alg,\"{pubkey_file}\") -m int 1 }}{jwt_condition}") + + # Validate expiration + lines.append("") + lines.append("# Validate expiration") + lines.append(f"http-request set-var(txn.now) date(){jwt_condition}") + lines.append(f"http-request deny content-type 'text/html' string 'JWT has expired' if {{ var(txn.exp),sub(txn.now) -m int lt 0 }}{jwt_condition}") + + haproxy_config = "\n".join(lines) + + # Build metadata + metadata = { + "domain": context.domain, + "algorithm": self.algorithm, + "pubkey_file": pubkey_file, + "validates_issuer": self.issuer is not None, + "validates_audience": self.audience is not None, + "path_validation": len(self.paths) > 0, + "only_paths": self.only_paths, + "allow_anonymous": self.allow_anonymous + } + + if self.issuer: + metadata["issuer"] = self.issuer + if self.audience: + metadata["audience"] = self.audience + if self.pubkey: + metadata["pubkey_content"] = self.pubkey + if self.paths: + metadata["paths"] = self.paths + + return PluginResult( + haproxy_config=haproxy_config, + modified_easymapping=None, + metadata=metadata + ) diff --git a/src/processor/__init__.py b/src/processor/__init__.py index ec038bf..0695a46 100644 --- a/src/processor/__init__.py +++ b/src/processor/__init__.py @@ -116,6 +116,30 @@ class Static(ProcessorInterface): def parse(self): self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader) + + # Merge plugin config from YAML with env vars + if "plugins" in self.static_content: + # Get env var config + container_env = ContainerEnv.read() + + # Merge YAML plugins config with env config + # YAML config takes precedence over env vars + if "plugins" not in self.static_content: + self.static_content["plugins"] = container_env.get("plugins", {}) + else: + # Merge configs - YAML overrides env vars + yaml_plugins = self.static_content["plugins"] + env_plugins = container_env.get("plugins", {}) + + # Merge individual plugin configs + for plugin_name, plugin_config in env_plugins.get("config", {}).items(): + if plugin_name not in yaml_plugins: + yaml_plugins[plugin_name] = {} + # Env vars fill in missing keys, YAML takes precedence + for key, value in plugin_config.items(): + if key not in yaml_plugins[plugin_name]: + yaml_plugins[plugin_name][key] = value + self.cfg = HaproxyConfigGenerator(self.static_content) @@ -227,6 +251,13 @@ class Kubernetes(ProcessorInterface): redirect = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect") mode = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.mode") listen_port = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.listen_port", 80) + plugins = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.plugins") + + # Extract plugin-specific configurations + plugin_annotations = {} + for annotation_key, annotation_value in ingress.metadata.annotations.items(): + if annotation_key.startswith("easyhaproxy.plugin."): + plugin_annotations[annotation_key] = annotation_value data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"), "resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace} @@ -273,6 +304,16 @@ class Kubernetes(ProcessorInterface): rule_data["%s.mode" % definition] = mode rule_data["%s.balance" % definition] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin") + # Add plugin configuration + if plugins is not None: + rule_data["%s.plugins" % definition] = plugins + + # Add plugin-specific configurations + for plugin_key, plugin_value in plugin_annotations.items(): + # Convert easyhaproxy.plugin.X.Y to easyhaproxy.{definition}.plugin.X.Y + plugin_config_key = plugin_key.replace("easyhaproxy.plugin.", "%s.plugin." % definition) + rule_data[plugin_config_key] = plugin_value + service_name = rule.http.paths[0].backend.service.name try: api_response = self.api_instance.read_namespaced_service(service_name, ingress.metadata.namespace) diff --git a/src/templates/haproxy.cfg.j2 b/src/templates/haproxy.cfg.j2 index d2df424..ca69453 100644 --- a/src/templates/haproxy.cfg.j2 +++ b/src/templates/haproxy.cfg.j2 @@ -40,6 +40,13 @@ defaults errorfile 504 /etc/haproxy/errors-custom/504.http {% endif %} +{% if global_plugin_configs %} +# Global Plugin Configurations +{% for config in global_plugin_configs %} +{{ config }} +{% endfor %} +{% endif %} + {% set data_stats = data["stats"] | default({}) %} {% if data_stats["port"] | default(1936) | int > 0 %} frontend stats @@ -75,6 +82,12 @@ frontend {{ mode }}_in_{{ o["port"] }} backend srv_{{ host }} balance {{ o["balance"] | default("roundrobin") }} mode {{ mode }} + {% if o["hosts"][k]["plugin_configs"] is defined and o["hosts"][k]["plugin_configs"] | length > 0 %} + # Domain Plugin Configurations for {{ k }} + {% for config in o["hosts"][k]["plugin_configs"] %} +{{ config | indent(4, first=True) }} + {% endfor %} + {% endif %} {% if mode == "http" %} option forwardfor http-request set-header X-Forwarded-Port %[dst_port] @@ -84,7 +97,7 @@ backend srv_{{ host }} tcp-check connect{{ " ssl" if o["ssl-check"] == "ssl" }} {% endif %} {% for c in o["hosts"][k]["containers"] %} - server srv-{{ loop.index0 }} {{ c }} check weight 1{{ " verify none" if o["ssl-check"] == "ssl" }} + server srv-{{ loop.index0 }} {{ c }} check weight 1{{ " verify none" if o["ssl-check"] == "ssl" }}{{ " proto " + o["hosts"][k]["proto"] if o["hosts"][k].get("proto") }} {% endfor %} {% endfor %} {% endfor %} diff --git a/src/tests/expected/docker.txt b/src/tests/expected/docker.txt index b522852..536d339 100644 --- a/src/tests/expected/docker.txt +++ b/src/tests/expected/docker.txt @@ -22,6 +22,7 @@ defaults timeout client 10s timeout server 10m + frontend stats bind *:1936 mode http diff --git a/src/tests/expected/no-services.txt b/src/tests/expected/no-services.txt index 7624237..a898508 100644 --- a/src/tests/expected/no-services.txt +++ b/src/tests/expected/no-services.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-fcgi.txt b/src/tests/expected/services-fcgi.txt new file mode 100644 index 0000000..f7c1bf5 --- /dev/null +++ b/src/tests/expected/services-fcgi.txt @@ -0,0 +1,56 @@ +global + log stdout format raw local0 info + maxconn 2000 + tune.ssl.default-dh-param 2048 + + # intermediate configuration + ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets + + ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 + ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 + ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets + + ssl-dh-param-file /etc/haproxy/dhparam + +defaults + log global + option httplog + + timeout connect 3s + timeout client 10s + timeout server 10m + + + +frontend http_in_80 + bind *:80 + mode http + + acl is_rule_phpapp_local_80_1 hdr(host) -i phpapp.local + acl is_rule_phpapp_local_80_2 hdr(host) -i phpapp.local:80 + use_backend srv_phpapp_local_80 if is_rule_phpapp_local_80_1 OR is_rule_phpapp_local_80_2 + + acl is_rule_phpapp-tcp_local_80_1 hdr(host) -i phpapp-tcp.local + acl is_rule_phpapp-tcp_local_80_2 hdr(host) -i phpapp-tcp.local:80 + use_backend srv_phpapp-tcp_local_80 if is_rule_phpapp-tcp_local_80_1 OR is_rule_phpapp-tcp_local_80_2 + +backend srv_phpapp_local_80 + balance roundrobin + mode http + option forwardfor + http-request set-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto https if { ssl_fc } + server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi +backend srv_phpapp-tcp_local_80 + balance roundrobin + mode http + option forwardfor + http-request set-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto https if { ssl_fc } + server srv-0 172.17.0.3:9000 check weight 1 proto fcgi + +backend certbot_backend + mode http + server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/services-letsencrypt.txt b/src/tests/expected/services-letsencrypt.txt index d95732d..ffb28c6 100644 --- a/src/tests/expected/services-letsencrypt.txt +++ b/src/tests/expected/services-letsencrypt.txt @@ -29,6 +29,7 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http + frontend stats bind *:1936 mode http diff --git a/src/tests/expected/services-multi-containers.txt b/src/tests/expected/services-multi-containers.txt index 3b1644d..0cfedd8 100644 --- a/src/tests/expected/services-multi-containers.txt +++ b/src/tests/expected/services-multi-containers.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + frontend http_in_19901 bind *:19901 mode http diff --git a/src/tests/expected/services-multiple-hosts.txt b/src/tests/expected/services-multiple-hosts.txt index 66810d5..0b3a64d 100644 --- a/src/tests/expected/services-multiple-hosts.txt +++ b/src/tests/expected/services-multiple-hosts.txt @@ -29,6 +29,7 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http + frontend stats bind *:1937 mode http diff --git a/src/tests/expected/services-redirect-ssl.txt b/src/tests/expected/services-redirect-ssl.txt index ad841cc..8c51afc 100644 --- a/src/tests/expected/services-redirect-ssl.txt +++ b/src/tests/expected/services-redirect-ssl.txt @@ -21,6 +21,7 @@ defaults timeout server 10m + frontend http_in_80 bind *:80 mode http diff --git a/src/tests/expected/services-tcp.txt b/src/tests/expected/services-tcp.txt index 2c19128..f9a41bd 100644 --- a/src/tests/expected/services-tcp.txt +++ b/src/tests/expected/services-tcp.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + frontend tcp_in_31339 bind *:31339 mode tcp diff --git a/src/tests/expected/services.txt b/src/tests/expected/services.txt index 82f8a6c..2c876f9 100644 --- a/src/tests/expected/services.txt +++ b/src/tests/expected/services.txt @@ -23,6 +23,7 @@ defaults timeout server 10m + frontend tcp_in_31339 bind *:31339 mode tcp diff --git a/src/tests/expected/ssl-loose.txt b/src/tests/expected/ssl-loose.txt index 239e566..e9cb61f 100644 --- a/src/tests/expected/ssl-loose.txt +++ b/src/tests/expected/ssl-loose.txt @@ -20,6 +20,7 @@ defaults timeout client 10s timeout server 10m + frontend stats bind *:1936 mode http diff --git a/src/tests/expected/ssl-strict.txt b/src/tests/expected/ssl-strict.txt index 7c3306f..0f26d3c 100644 --- a/src/tests/expected/ssl-strict.txt +++ b/src/tests/expected/ssl-strict.txt @@ -18,6 +18,7 @@ defaults timeout server 10m + backend certbot_backend mode http server certbot 127.0.0.1:2080 diff --git a/src/tests/expected/static.txt b/src/tests/expected/static.txt index 1d0d9a2..2002c62 100644 --- a/src/tests/expected/static.txt +++ b/src/tests/expected/static.txt @@ -29,6 +29,7 @@ defaults errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 504 /etc/haproxy/errors-custom/504.http + frontend stats bind *:1936 mode http diff --git a/src/tests/fixtures/services-fcgi b/src/tests/fixtures/services-fcgi new file mode 100644 index 0000000..d4fdc12 --- /dev/null +++ b/src/tests/fixtures/services-fcgi @@ -0,0 +1,16 @@ +{ + "172.17.0.2": { + "easyhaproxy.definitions": "fcgi", + "easyhaproxy.fcgi.host": "phpapp.local", + "easyhaproxy.fcgi.port": "80", + "easyhaproxy.fcgi.socket": "/run/php/php-fpm.sock", + "easyhaproxy.fcgi.proto": "fcgi" + }, + "172.17.0.3": { + "easyhaproxy.definitions": "fcgi-tcp", + "easyhaproxy.fcgi-tcp.host": "phpapp-tcp.local", + "easyhaproxy.fcgi-tcp.port": "80", + "easyhaproxy.fcgi-tcp.localport": "9000", + "easyhaproxy.fcgi-tcp.proto": "fcgi" + } +} diff --git a/src/tests/fixtures/services-with-cloudflare b/src/tests/fixtures/services-with-cloudflare new file mode 100644 index 0000000..902c62d --- /dev/null +++ b/src/tests/fixtures/services-with-cloudflare @@ -0,0 +1,8 @@ +{ + "192.168.1.10": { + "easyhaproxy.http.host": "example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "cloudflare" + } +} diff --git a/src/tests/fixtures/services-with-deny-pages b/src/tests/fixtures/services-with-deny-pages new file mode 100644 index 0000000..b592182 --- /dev/null +++ b/src/tests/fixtures/services-with-deny-pages @@ -0,0 +1,10 @@ +{ + "192.168.1.20": { + "easyhaproxy.http.host": "secure.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "deny_pages", + "easyhaproxy.http.plugin.deny_pages.paths": "/admin,/wp-admin", + "easyhaproxy.http.plugin.deny_pages.status_code": "404" + } +} diff --git a/src/tests/fixtures/services-with-ip-whitelist b/src/tests/fixtures/services-with-ip-whitelist new file mode 100644 index 0000000..0e21abf --- /dev/null +++ b/src/tests/fixtures/services-with-ip-whitelist @@ -0,0 +1,10 @@ +{ + "192.168.1.40": { + "easyhaproxy.http.host": "secure.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "ip_whitelist", + "easyhaproxy.http.plugin.ip_whitelist.allowed_ips": "192.168.1.0/24,10.0.0.5", + "easyhaproxy.http.plugin.ip_whitelist.status_code": "403" + } +} diff --git a/src/tests/fixtures/services-with-jwt-validator b/src/tests/fixtures/services-with-jwt-validator new file mode 100644 index 0000000..4b1957e --- /dev/null +++ b/src/tests/fixtures/services-with-jwt-validator @@ -0,0 +1,12 @@ +{ + "192.168.1.50": { + "easyhaproxy.http.host": "api.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "jwt_validator", + "easyhaproxy.http.plugin.jwt_validator.algorithm": "RS256", + "easyhaproxy.http.plugin.jwt_validator.issuer": "https://auth.example.com/", + "easyhaproxy.http.plugin.jwt_validator.audience": "https://api.example.com", + "easyhaproxy.http.plugin.jwt_validator.pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + } +} diff --git a/src/tests/fixtures/services-with-multiple-plugins b/src/tests/fixtures/services-with-multiple-plugins new file mode 100644 index 0000000..1b13766 --- /dev/null +++ b/src/tests/fixtures/services-with-multiple-plugins @@ -0,0 +1,11 @@ +{ + "192.168.1.30": { + "easyhaproxy.http.host": "multi.example.com", + "easyhaproxy.http.port": "80", + "easyhaproxy.http.localport": "8080", + "easyhaproxy.http.plugins": "cloudflare,deny_pages", + "easyhaproxy.http.plugin.cloudflare.ip_list_path": "/etc/haproxy/cloudflare_ips.lst", + "easyhaproxy.http.plugin.deny_pages.paths": "/admin,/private", + "easyhaproxy.http.plugin.deny_pages.status_code": "403" + } +} diff --git a/src/tests/test_containerenv.py b/src/tests/test_containerenv.py index 27d672e..f5837d7 100644 --- a/src/tests/test_containerenv.py +++ b/src/tests/test_containerenv.py @@ -20,7 +20,12 @@ def test_container_env_empty(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() # os.environ['CERTBOT_LOG_LEVEL'] = 'warn' @@ -45,7 +50,12 @@ def test_container_env_customerrors(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_CUSTOMERRORS'] @@ -70,7 +80,12 @@ def test_container_env_sslmode(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['EASYHAPROXY_SSL_MODE'] @@ -96,7 +111,12 @@ def test_container_env_stats(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_USERNAME'] @@ -128,7 +148,12 @@ def test_container_env_stats_password(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_PASSWORD'] @@ -160,7 +185,12 @@ def test_container_env_stats_password_2(): "server": False, "retry_count": 60, "preferred_challenges": "http", - "manual_auth_hook": False} + "manual_auth_hook": False}, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] + } } == ContainerEnv.read() finally: del os.environ['HAPROXY_USERNAME'] @@ -189,6 +219,11 @@ def test_container_env_certbot_email(): "retry_count": 60, "preferred_challenges": "http", "manual_auth_hook": False + }, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] } } == ContainerEnv.read() finally: @@ -222,6 +257,11 @@ def test_container_env_certbot_full(): 'retry_count': 10, "preferred_challenges": "dns", "manual_auth_hook": "something_manual_auth_hook" + }, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] } } == ContainerEnv.read() finally: @@ -257,6 +297,11 @@ def test_container_log_level(): "retry_count": 60, "preferred_challenges": "http", "manual_auth_hook": False + }, + "plugins": { + "abort_on_error": False, + "config": {}, + "enabled": [] } } == ContainerEnv.read() finally: diff --git a/src/tests/test_parser.py b/src/tests/test_parser.py index 3f6f1fa..519ffa0 100644 --- a/src/tests/test_parser.py +++ b/src/tests/test_parser.py @@ -124,7 +124,9 @@ def test_parser_finds_services_raw(): "my-stack_agent:9001" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -142,7 +144,9 @@ def test_parser_finds_services_raw(): "my-stack_cadvisor:8080" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] }, "node-exporter.quantum.example.org":{ "balance": "roundrobin", @@ -150,7 +154,9 @@ def test_parser_finds_services_raw(): "my-stack_node-exporter:9100" ], "certbot": True, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -168,7 +174,9 @@ def test_parser_finds_services_raw(): "my-stack_node-exporter:9100" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] }, "www.somehost.com.br":{ "balance": "roundrobin", @@ -176,7 +184,9 @@ def test_parser_finds_services_raw(): "some-service:80" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -199,7 +209,9 @@ def test_parser_finds_services_raw(): "some-service:80" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] } }, "redirect": { @@ -465,7 +477,9 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.215:8080" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] }, "valida.me":{ "balance":"roundrobin", @@ -473,7 +487,9 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.62:8080" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] }, "www.valida.me":{ "balance":"roundrobin", @@ -481,7 +497,9 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.62:8080" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] } }, "mode": "http", @@ -499,7 +517,9 @@ def test_parser_finds_services_clone_to_ssl_raw(): "10.152.183.215:8080" ], "certbot": False, - "redirect_ssl": False + "proto": "", + "redirect_ssl": False, + "plugin_configs": [] } }, "mode": "http", @@ -515,6 +535,37 @@ def test_parser_finds_services_clone_to_ssl_raw(): assert parsed_object == processed assert [] == cfg.certbot_hosts +def test_parser_fcgi(): + """Test FastCGI support with proto and socket parameters""" + line_list = load_fixture("services-fcgi") + + result = { + "customerrors": False, + "stats": { + "port": 0 + } + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + assert len(haproxy_config) > 0 + + # Verify proto fcgi is in the output + assert "proto fcgi" in haproxy_config + + # Verify Unix socket path is used + assert "/run/php/php-fpm.sock" in haproxy_config + + # Verify TCP connection is also present + assert "172.17.0.3:9000" in haproxy_config + + path = os.path.dirname(os.path.realpath(__file__)) + with open(path + "/expected/services-fcgi.txt", 'r') as expected_file: + assert expected_file.read() == haproxy_config + assert [] == cfg.certbot_hosts + + # test_parser_finds_services_raw() # test_parser_tcp() # test_parser_multiple_hosts() diff --git a/src/tests/test_plugins.py b/src/tests/test_plugins.py new file mode 100644 index 0000000..ae53f4c --- /dev/null +++ b/src/tests/test_plugins.py @@ -0,0 +1,1078 @@ +""" +Tests for EasyHAProxy Plugin System + +Tests all builtin plugins: +- CloudflarePlugin (domain) +- CleanupPlugin (global) +- DenyPagesPlugin (domain) +""" + +import os +import sys +import json +import tempfile +import time + +# Add src to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginManager, PluginContext +from plugins.builtin.cloudflare import CloudflarePlugin +from plugins.builtin.cleanup import CleanupPlugin +from plugins.builtin.deny_pages import DenyPagesPlugin +from plugins.builtin.ip_whitelist import IpWhitelistPlugin +from plugins.builtin.jwt_validator import JwtValidatorPlugin +from plugins.builtin.fastcgi import FastcgiPlugin +import easymapping + + +def load_fixture(file): + """Load a test fixture""" + fixture_path = os.path.join(os.path.dirname(__file__), "fixtures", file) + with open(fixture_path, 'r') as content_file: + line_list = json.loads("".join(content_file.readlines())) + return line_list + + +class TestCloudflarePlugin: + """Test cases for CloudflarePlugin (DOMAIN plugin)""" + + def test_cloudflare_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = CloudflarePlugin() + assert plugin.name == "cloudflare" + assert plugin.enabled is True + assert plugin.use_builtin_ips is True + assert plugin.ip_list_path == "/etc/haproxy/cloudflare_ips.lst" + assert len(plugin.CLOUDFLARE_IPS) == 22 # 15 IPv4 + 7 IPv6 + + def test_cloudflare_plugin_configuration(self): + """Test plugin configuration""" + plugin = CloudflarePlugin() + + # Test custom IP list path + plugin.configure({"ip_list_path": "/custom/path/cf_ips.txt"}) + assert plugin.ip_list_path == "/custom/path/cf_ips.txt" + + # Test disabling + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + # Test enabling with various values + plugin.configure({"enabled": "true"}) + assert plugin.enabled is True + + plugin.configure({"enabled": "1"}) + assert plugin.enabled is True + + plugin.configure({"enabled": "yes"}) + assert plugin.enabled is True + + def test_cloudflare_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = CloudflarePlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + 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 result.metadata["domain"] == "example.com" + assert result.metadata["ip_list_path"] == "/etc/haproxy/cloudflare_ips.lst" + + def test_cloudflare_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = CloudflarePlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_cloudflare_plugin_in_haproxy_config(self): + """Test Cloudflare plugin integration in full HAProxy config generation""" + # Use fixture with cloudflare plugin enabled via labels + line_list = load_fixture("services-with-cloudflare") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # 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 + + def test_cloudflare_plugin_builtin_ips_enabled(self): + """Test plugin uses built-in Cloudflare IPs and writes to file""" + # Use temp directory for testing + with tempfile.TemporaryDirectory() as tmpdir: + ip_list_path = os.path.join(tmpdir, "cloudflare_ips.lst") + + plugin = CloudflarePlugin() + plugin.configure({ + "use_builtin_ips": "true", + "ip_list_path": ip_list_path + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + # Verify config is generated + assert result.haproxy_config is not None + assert f"acl from_cloudflare src -f {ip_list_path}" in result.haproxy_config + + # Verify metadata + assert result.metadata["use_builtin_ips"] is True + assert result.metadata["ip_count"] == 22 + + # Verify file was written + assert os.path.exists(ip_list_path) + + # Verify file contains correct number of IPs + with open(ip_list_path, 'r') as f: + lines = [line.strip() for line in f if line.strip()] + assert len(lines) == 22 + # Verify some known Cloudflare IPs are in the file + assert "173.245.48.0/20" in lines + assert "2606:4700::/32" in lines + + def test_cloudflare_plugin_builtin_ips_disabled(self): + """Test plugin doesn't write to file when use_builtin_ips is disabled""" + plugin = CloudflarePlugin() + plugin.configure({ + "use_builtin_ips": "false", + "ip_list_path": "/custom/cloudflare_ips.lst" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + # Verify config is generated with custom path + assert result.haproxy_config is not None + assert "acl from_cloudflare src -f /custom/cloudflare_ips.lst" in result.haproxy_config + + # Verify metadata + assert result.metadata["use_builtin_ips"] is False + assert result.metadata["ip_count"] is None + + +class TestCleanupPlugin: + """Test cases for CleanupPlugin (GLOBAL plugin)""" + + def test_cleanup_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = CleanupPlugin() + assert plugin.name == "cleanup" + assert plugin.enabled is True + assert plugin.max_idle_time == 300 + assert plugin.cleanup_temp_files is True + + def test_cleanup_plugin_configuration(self): + """Test plugin configuration""" + plugin = CleanupPlugin() + + # Test max_idle_time + plugin.configure({"max_idle_time": "600"}) + assert plugin.max_idle_time == 600 + + # Test cleanup_temp_files + plugin.configure({"cleanup_temp_files": "false"}) + assert plugin.cleanup_temp_files is False + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_cleanup_plugin_processes_files(self): + """Test plugin cleans up old temp files""" + plugin = CleanupPlugin() + plugin.configure({"max_idle_time": "1"}) # 1 second + + # Create a temp file + with tempfile.NamedTemporaryFile(prefix="easyhaproxy_", delete=False) as tmp: + temp_file = tmp.name + + # Wait for file to age + time.sleep(2) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={} + ) + + # Run cleanup + result = plugin.process(context) + + # Verify file was removed + assert not os.path.exists(temp_file) + assert result.haproxy_config == "" + assert result.metadata["actions_performed"] >= 0 + + def test_cleanup_plugin_disabled(self): + """Test plugin does nothing when disabled""" + plugin = CleanupPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={} + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_cleanup_plugin_in_haproxy_config(self): + """Test Cleanup plugin integration (should not affect config output)""" + line_list = load_fixture("services") + + # Enable cleanup plugin + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0}, + "plugins": { + "enabled": ["cleanup"], + "config": { + "cleanup": { + "max_idle_time": "300" + } + } + } + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Cleanup plugin should not add any HAProxy config + assert "cleanup" not in haproxy_config.lower() + # But the config should still be valid + assert "backend certbot_backend" in haproxy_config + + +class TestDenyPagesPlugin: + """Test cases for DenyPagesPlugin (DOMAIN plugin)""" + + def test_deny_pages_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = DenyPagesPlugin() + assert plugin.name == "deny_pages" + assert plugin.enabled is True + assert plugin.paths == [] + assert plugin.status_code == 403 + + def test_deny_pages_plugin_configuration(self): + """Test plugin configuration""" + plugin = DenyPagesPlugin() + + # Test paths + plugin.configure({"paths": "/admin,/private,/internal"}) + assert plugin.paths == ["/admin", "/private", "/internal"] + + # Test status code + plugin.configure({"status_code": "404"}) + assert plugin.status_code == 404 + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_deny_pages_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = DenyPagesPlugin() + plugin.configure({ + "paths": "/admin,/private", + "status_code": "403" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "Deny Pages" in result.haproxy_config + assert "acl denied_path path_beg /admin /private" in result.haproxy_config + assert "http-request deny deny_status 403 if denied_path" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["blocked_paths"] == ["/admin", "/private"] + assert result.metadata["status_code"] == 403 + + def test_deny_pages_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = DenyPagesPlugin() + plugin.configure({"enabled": "false", "paths": "/admin"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_deny_pages_plugin_no_paths(self): + """Test plugin returns empty config when no paths configured""" + plugin = DenyPagesPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_deny_pages_plugin_in_haproxy_config(self): + """Test Deny Pages plugin integration in full HAProxy config generation""" + # Use fixture with deny_pages plugin enabled via labels + line_list = load_fixture("services-with-deny-pages") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify Deny Pages config is in the output + assert "Deny Pages - Block specific paths" in haproxy_config + assert "acl denied_path path_beg /admin /wp-admin" in haproxy_config + assert "http-request deny deny_status 404 if denied_path" in haproxy_config + + +class TestIpWhitelistPlugin: + """Test cases for IpWhitelistPlugin (DOMAIN plugin)""" + + def test_ip_whitelist_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = IpWhitelistPlugin() + assert plugin.name == "ip_whitelist" + assert plugin.enabled is True + assert plugin.allowed_ips == [] + assert plugin.status_code == 403 + + def test_ip_whitelist_plugin_configuration(self): + """Test plugin configuration""" + plugin = IpWhitelistPlugin() + + # Test allowed IPs + plugin.configure({"allowed_ips": "192.168.1.0/24,10.0.0.1,172.16.0.0/16"}) + assert plugin.allowed_ips == ["192.168.1.0/24", "10.0.0.1", "172.16.0.0/16"] + + # Test status code + plugin.configure({"status_code": "404"}) + assert plugin.status_code == 404 + + # Test enabled + plugin.configure({"enabled": "false"}) + assert plugin.enabled is False + + def test_ip_whitelist_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = IpWhitelistPlugin() + plugin.configure({ + "allowed_ips": "192.168.1.0/24,10.0.0.1", + "status_code": "403" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "IP Whitelist" in result.haproxy_config + assert "acl whitelisted_ip src 192.168.1.0/24 10.0.0.1" in result.haproxy_config + assert "http-request deny deny_status 403 if !whitelisted_ip" in result.haproxy_config + assert result.metadata["domain"] == "example.com" + assert result.metadata["allowed_ips"] == ["192.168.1.0/24", "10.0.0.1"] + assert result.metadata["status_code"] == 403 + + def test_ip_whitelist_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = IpWhitelistPlugin() + plugin.configure({"enabled": "false", "allowed_ips": "192.168.1.0/24"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_ip_whitelist_plugin_no_ips(self): + """Test plugin returns empty config when no IPs configured""" + plugin = IpWhitelistPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_ip_whitelist_plugin_in_haproxy_config(self): + """Test IP Whitelist plugin integration in full HAProxy config generation""" + # Use fixture with ip_whitelist plugin enabled via labels + line_list = load_fixture("services-with-ip-whitelist") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify IP Whitelist config is in the output + assert "IP Whitelist - Only allow specific IPs" in haproxy_config + assert "acl whitelisted_ip src 192.168.1.0/24 10.0.0.5" in haproxy_config + assert "http-request deny deny_status 403 if !whitelisted_ip" in haproxy_config + + +class TestJwtValidatorPlugin: + """Test cases for JwtValidatorPlugin (DOMAIN plugin)""" + + def test_jwt_validator_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = JwtValidatorPlugin() + assert plugin.name == "jwt_validator" + assert plugin.enabled is True + assert plugin.algorithm == "RS256" + assert plugin.issuer is None + assert plugin.audience is None + assert plugin.pubkey_path is None + assert plugin.pubkey is None + + def test_jwt_validator_plugin_configuration(self): + """Test plugin configuration""" + plugin = JwtValidatorPlugin() + + # Test basic config + plugin.configure({ + "algorithm": "RS512", + "issuer": "https://auth.example.com/", + "audience": "https://api.example.com", + "pubkey_path": "/etc/haproxy/keys/api.pem" + }) + assert plugin.algorithm == "RS512" + assert plugin.issuer == "https://auth.example.com/" + assert plugin.audience == "https://api.example.com" + assert plugin.pubkey_path == "/etc/haproxy/keys/api.pem" + + # Test empty values skip validation (use fresh plugin) + plugin2 = JwtValidatorPlugin() + plugin2.configure({ + "issuer": "", + "audience": "" + }) + assert plugin2.issuer is None + assert plugin2.audience is None + + # Test not providing issuer/audience at all (use fresh plugin) + plugin2b = JwtValidatorPlugin() + plugin2b.configure({ + "algorithm": "RS256", + "pubkey_path": "/etc/haproxy/keys/api.pem" + }) + assert plugin2b.issuer is None + assert plugin2b.audience is None + + # Test enabled (use fresh plugin) + plugin3 = JwtValidatorPlugin() + plugin3.configure({"enabled": "false"}) + assert plugin3.enabled is False + + def test_jwt_validator_plugin_generates_config_with_path(self): + """Test plugin generates correct HAProxy config using pubkey_path""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "algorithm": "RS256", + "issuer": "https://auth.example.com/", + "audience": "https://api.example.com", + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "JWT Validator" in result.haproxy_config + assert "Missing Authorization HTTP header" in result.haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in result.haproxy_config + assert "http-request set-var(txn.iss) http_auth_bearer,jwt_payload_query('$.iss')" in result.haproxy_config + assert "http-request set-var(txn.aud) http_auth_bearer,jwt_payload_query('$.aud')" in result.haproxy_config + assert "http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')" in result.haproxy_config + assert "var(txn.alg) -m str RS256" in result.haproxy_config + assert "var(txn.iss) -m str https://auth.example.com/" in result.haproxy_config + assert "var(txn.aud) -m str https://api.example.com" in result.haproxy_config + assert 'jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem")' in result.haproxy_config + assert "JWT has expired" in result.haproxy_config + assert result.metadata["domain"] == "api.example.com" + assert result.metadata["algorithm"] == "RS256" + assert result.metadata["validates_issuer"] is True + assert result.metadata["validates_audience"] is True + + def test_jwt_validator_plugin_generates_config_with_pubkey_content(self): + """Test plugin generates correct HAProxy config using pubkey content (base64-encoded)""" + plugin = JwtValidatorPlugin() + # Base64-encoded version of "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + pubkey_base64 = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaC4uLgotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0=" + plugin.configure({ + "algorithm": "RS256", + "pubkey": pubkey_base64 + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "JWT Validator" in result.haproxy_config + assert "/etc/haproxy/jwt_keys/api_example_com_pubkey.pem" in result.haproxy_config + # Verify the decoded content is stored in metadata + assert result.metadata["pubkey_content"] == "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh...\n-----END PUBLIC KEY-----" + + def test_jwt_validator_plugin_no_issuer_audience_validation(self): + """Test plugin skips issuer/audience validation when not configured""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "Invalid JWT issuer" not in result.haproxy_config + assert "Invalid JWT audience" not in result.haproxy_config + assert result.metadata["validates_issuer"] is False + assert result.metadata["validates_audience"] is False + + def test_jwt_validator_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "enabled": "false", + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + assert result.metadata == {} + + def test_jwt_validator_plugin_no_pubkey(self): + """Test plugin returns empty config when no pubkey configured""" + plugin = JwtValidatorPlugin() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" + + def test_jwt_validator_plugin_in_haproxy_config(self): + """Test JWT Validator plugin integration in full HAProxy config generation""" + line_list = load_fixture("services-with-jwt-validator") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify JWT Validator config is in the output + assert "JWT Validator - Validate JWT tokens" in haproxy_config + assert "Missing Authorization HTTP header" in haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg')" in haproxy_config + assert "jwt_verify" in haproxy_config + + def test_jwt_validator_plugin_with_paths_only_paths_false(self): + """Test plugin with paths configured and only_paths=false""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/admin", "/api/sensitive"], + "only_paths": "false" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that ACLs are defined for paths + assert "acl jwt_protected_path path_beg /api/admin" in result.haproxy_config + assert "acl jwt_protected_path path_beg /api/sensitive" in result.haproxy_config + # Check that validation rules have "if jwt_protected_path" condition + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" in result.haproxy_config + assert "http-request set-var(txn.alg) http_auth_bearer,jwt_header_query('$.alg') if jwt_protected_path" in result.haproxy_config + # Check that "Access denied" for non-protected paths is NOT present (only_paths=false) + assert "Access denied" not in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is True + assert result.metadata["only_paths"] is False + assert result.metadata["paths"] == ["/api/admin", "/api/sensitive"] + + def test_jwt_validator_plugin_with_paths_only_paths_true(self): + """Test plugin with paths configured and only_paths=true""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/public"], + "only_paths": "true" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that ACL is defined for path + assert "acl jwt_protected_path path_beg /api/public" in result.haproxy_config + # Check that "Access denied" for non-protected paths IS present (only_paths=true) + assert "http-request deny content-type 'text/html' string 'Access denied' unless jwt_protected_path" in result.haproxy_config + # Check that validation rules do NOT have "if jwt_protected_path" (since all non-protected paths are denied) + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config + # The rules should not have any condition suffix when only_paths=true + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is True + assert result.metadata["only_paths"] is True + assert result.metadata["paths"] == ["/api/public"] + + def test_jwt_validator_plugin_paths_from_comma_separated_string(self): + """Test plugin parses comma-separated paths from container labels""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": "/api/admin,/api/sensitive,/api/protected" + }) + + assert plugin.paths == ["/api/admin", "/api/sensitive", "/api/protected"] + + def test_jwt_validator_plugin_paths_from_list(self): + """Test plugin parses paths from list (YAML config)""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem", + "paths": ["/api/admin", "/api/sensitive"] + }) + + assert plugin.paths == ["/api/admin", "/api/sensitive"] + + def test_jwt_validator_plugin_no_paths_protects_all(self): + """Test plugin protects all paths when paths is not configured""" + plugin = JwtValidatorPlugin() + plugin.configure({ + "pubkey_path": "/etc/haproxy/jwt_keys/api_pubkey.pem" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="api.example.com", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + # Check that no ACL is defined + assert "acl jwt_protected_path" not in result.haproxy_config + # Check that validation rules do NOT have any condition suffix (all paths protected) + assert "unless { req.hdr(authorization) -m found } if jwt_protected_path" not in result.haproxy_config + assert "http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }" in result.haproxy_config + # Check metadata + assert result.metadata["path_validation"] is False + + +class TestFastcgiPlugin: + """Test cases for FastcgiPlugin""" + + def test_fastcgi_plugin_initialization(self): + """Test plugin initializes with correct defaults""" + plugin = FastcgiPlugin() + + assert plugin.name == "fastcgi" + assert plugin.enabled is True + assert plugin.document_root == "/var/www/html" + assert plugin.index_file == "index.php" + assert plugin.path_info is True + assert plugin.custom_params == {} + + def test_fastcgi_plugin_configuration(self): + """Test plugin configuration""" + plugin = FastcgiPlugin() + plugin.configure({ + "document_root": "/var/www/myapp", + "index_file": "app.php", + "path_info": "false" + }) + + assert plugin.document_root == "/var/www/myapp" + assert plugin.index_file == "app.php" + assert plugin.path_info is False + + def test_fastcgi_plugin_generates_config(self): + """Test plugin generates correct HAProxy config""" + plugin = FastcgiPlugin() + plugin.configure({ + "document_root": "/var/www/html", + "index_file": "index.php" + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="phpapp.local", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config + + # Check fcgi-app definition in metadata + assert "fcgi_app_definition" in result.metadata + fcgi_app_def = result.metadata["fcgi_app_definition"] + assert "fcgi-app fcgi_phpapp_local" in fcgi_app_def + assert "docroot /var/www/html" in fcgi_app_def + assert "index index.php" in fcgi_app_def + assert result.metadata["document_root"] == "/var/www/html" + assert result.metadata["index_file"] == "index.php" + + def test_fastcgi_plugin_custom_params(self): + """Test plugin with custom FastCGI parameters""" + plugin = FastcgiPlugin() + plugin.configure({ + "custom_params": { + "CUSTOM_VAR": "custom_value", + "APP_ENV": "production" + } + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="phpapp.local", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is not None + assert "use-fcgi-app fcgi_phpapp_local" in result.haproxy_config + + # Check custom params in fcgi-app definition in metadata + assert "fcgi_app_definition" in result.metadata + fcgi_app_def = result.metadata["fcgi_app_definition"] + assert "set-param CUSTOM_VAR custom_value" in fcgi_app_def + assert "set-param APP_ENV production" in fcgi_app_def + assert result.metadata["custom_params_count"] == 2 + + def test_fastcgi_plugin_disabled(self): + """Test plugin returns empty config when disabled""" + plugin = FastcgiPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="phpapp.local", + port="80", + host_config={} + ) + + result = plugin.process(context) + + assert result.haproxy_config is None or result.haproxy_config == "" + + +class TestPluginManager: + """Test cases for PluginManager""" + + def test_plugin_manager_loads_builtin_plugins(self): + """Test that plugin manager loads all builtin plugins""" + manager = PluginManager() + manager.load_plugins() + + # Verify all builtin plugins are loaded + assert "cloudflare" in manager.plugins + assert "cleanup" in manager.plugins + assert "deny_pages" in manager.plugins + assert "ip_whitelist" in manager.plugins + assert "jwt_validator" in manager.plugins + assert "fastcgi" in manager.plugins + + # Verify plugin types + assert len(manager.global_plugins) == 1 # cleanup + assert len(manager.domain_plugins) == 5 # cloudflare, deny_pages, ip_whitelist, jwt_validator, fastcgi + + # Verify plugin instances + assert manager.plugins["cloudflare"].name == "cloudflare" + assert manager.plugins["cleanup"].name == "cleanup" + assert manager.plugins["deny_pages"].name == "deny_pages" + assert manager.plugins["ip_whitelist"].name == "ip_whitelist" + assert manager.plugins["jwt_validator"].name == "jwt_validator" + + def test_plugin_manager_executes_global_plugins(self): + """Test plugin manager executes global plugins correctly""" + manager = PluginManager() + manager.load_plugins() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={} + ) + + # Execute only cleanup plugin + results = manager.execute_global_plugins(context, enabled_list=["cleanup"]) + + assert len(results) == 1 + assert results[0].haproxy_config == "" # Cleanup doesn't generate config + + def test_plugin_manager_executes_domain_plugins(self): + """Test plugin manager executes domain plugins correctly""" + manager = PluginManager() + manager.load_plugins() + + # Configure deny_pages + manager.configure_plugins({ + "deny_pages": { + "paths": "/admin,/private", + "status_code": "403" + } + }) + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com", + port="80", + host_config={} + ) + + # Execute both domain plugins + results = manager.execute_domain_plugins(context, enabled_list=["cloudflare", "deny_pages"]) + + assert len(results) == 2 + + # Verify configs were generated + configs = [r.haproxy_config for r in results if r.haproxy_config] + assert len(configs) == 2 + + # Verify both plugin outputs + all_config = "\n".join(configs) + assert "Cloudflare" in all_config + assert "Deny Pages" in all_config + + def test_plugin_manager_empty_enabled_list(self): + """Test that empty enabled list means no plugins execute""" + manager = PluginManager() + manager.load_plugins() + + context = PluginContext( + parsed_object={}, + easymapping=[], + container_env={}, + domain="example.com" + ) + + # Execute with empty list - should execute nothing + results = manager.execute_domain_plugins(context, enabled_list=[]) + assert len(results) == 0 + + results = manager.execute_global_plugins(context, enabled_list=[]) + assert len(results) == 0 + + +class TestMultiplePluginsCombined: + """Test cases for multiple plugins working together""" + + def test_multiple_plugins_in_haproxy_config(self): + """Test multiple plugins working together in HAProxy config""" + # Use fixture with both plugins enabled via labels + line_list = load_fixture("services-with-multiple-plugins") + + # Enable cleanup plugin globally + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0}, + "plugins": { + "enabled": ["cleanup"], + "config": { + "cleanup": { + "max_idle_time": "600" + } + } + } + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Verify both domain plugins are in the output + assert "Cloudflare - Restore original visitor IP" in haproxy_config + assert "Deny Pages - Block specific paths" in haproxy_config + assert "acl from_cloudflare" in haproxy_config + assert "acl denied_path path_beg /admin /private" in haproxy_config + + # Cleanup doesn't add to config + assert "cleanup" not in haproxy_config.lower() + + def test_plugins_order_in_output(self): + """Test that plugins maintain consistent order in output""" + # Use fixture with both plugins enabled via labels + line_list = load_fixture("services-with-multiple-plugins") + + result = { + "customerrors": False, + "certbot": {"email": "test@example.com"}, + "stats": {"port": 0} + } + + cfg = easymapping.HaproxyConfigGenerator(result) + haproxy_config = cfg.generate(line_list) + + # Find positions of plugin configs + cloudflare_pos = haproxy_config.find("Cloudflare") + deny_pages_pos = haproxy_config.find("Deny Pages") + + # Both should be present + assert cloudflare_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_")