diff --git a/README.md b/README.md index 677359b..47131ae 100644 --- a/README.md +++ b/README.md @@ -13,133 +13,48 @@ ## Service discovery for HAProxy -EasyHAProxy dynamically creates the `haproxy.cfg` based on metadata collected from your workloads (Docker labels, Swarm service labels, or Kubernetes ingress annotations). +EasyHAProxy dynamically creates `haproxy.cfg` based on metadata from your workloads — Docker labels, Swarm service labels, or Kubernetes Ingress annotations. No HAProxy knowledge required. -EasyHAProxy can detect and configure HAProxy automatically on the following platforms: +## Features -- Docker -- Docker Swarm -- Kubernetes -- Static YAML definitions (`EASYHAPROXY_DISCOVER=static`) +- **Automatic service discovery** — Docker, Docker Swarm, Kubernetes, or static YAML +- **Zero-downtime HAProxy reload** — configuration updates happen without dropping connections +- **Automatic TLS with ACME** — Let's Encrypt, ZeroSSL, BuyPass, and more +- **Custom SSL certificates** — volume-mount or label-embed your own PEM files +- **TCP mode** — proxy any TCP service, not just HTTP +- **Plugin system** — JWT validation, IP whitelisting, Cloudflare IP restoration, FastCGI, path blocking, and custom plugins +- **HAProxy stats dashboard** — optional, password-protected +- **Balance algorithms** — roundrobin, leastconn, source, uri, and more + +## Supported platforms + +[![Kubernetes](docs/easyhaproxy_kubernetes.png)](docs/getting-started/kubernetes.md) +[![Docker Swarm](docs/easyhaproxy_swarm.png)](docs/getting-started/swarm.md) +[![Docker](docs/easyhaproxy_docker.png)](docs/getting-started/docker.md) +[![Static](docs/easyhaproxy_static.png)](docs/getting-started/static.md) + +Install using tools: + +[![Helm](docs/easyhaproxy_helm.png)](docs/guides/helm.md) +[![MicroK8s](docs/easyhaproxy_microk8s.png)](docs/guides/microk8s.md) +[![Dokku](docs/easyhaproxy_dokku.png)](docs/guides/dokku.md) +[![DigitalOcean](docs/easyhaproxy_digitalocean.png)](docs/guides/digitalocean.md) + +## Documentation + +| Section | Description | +|---------|-------------| +| **[Getting Started](docs/getting-started/index.md)** | Choose your runtime and discovery mode, minimal working setup | +| **[Guides](docs/guides/ssl.md)** | SSL, ACME, plugins, Helm, MicroK8s, Dokku, DigitalOcean | +| **[Concepts](docs/concepts/index.md)** | Service discovery, config pipeline, plugin model, TLS termination | +| **[Reference](docs/reference/environment-variables.md)** | Environment variables, container labels, CLI flags, volumes | ## Who is using? EasyHAProxy is part of some projects: -- Dokku -- MicroK8s -- DigitalOcean Marketplace - -See detailed instructions on how to install below. - -## EasyHAProxy Mission - -Easy to set up and low configuration to numerous features. - -## Features - -EasyHAProxy will discover services based on Docker (or Swarm) labels and Kubernetes ingress annotations, then dynamically build the `haproxy.cfg`. Below, EasyHAProxy main features: - -- Support Automatic Certificate Management Environment (ACME) protocol compatible with Let's Encrypt and other CAs. -- Set your custom SSL certificates -- Balance traffic between multiple replicas -- Set SSL policies (`strict`, `default`, `loose`) via `EASYHAPROXY_SSL_MODE`. -- Set up HAProxy to listen to TCP. -- Add redirects. -- Enable/disable Stats on port 1936 with a custom password. -- Enable/disable custom errors. - -Also, it is possible to set up HAProxy from a simple Yaml file instead of creating `haproxy.cfg` file. - -## How Does It Work? - -You don't need to change your current infrastructure and don't need to learn the HAProxy configuration. - -The steps are: - -- Run the EasyHAProxy container; -- Add some labels to the containers you want to be parsed by EasyHAProxy (see detailed instructions below); -- EasyHAProxy will automatically detect the containers, set up, and reload the HAProxy configurations for you without downtime. - -## Detailed Instructions - -For detailed instructions on how to use EasyHAProxy, follow the instructions for the platform you want to use: - -[![Kubernetes](docs/easyhaproxy_kubernetes.png)](docs/kubernetes.md) -[![Docker Swarm](docs/easyhaproxy_swarm.png)](docs/swarm.md) -[![Docker](docs/easyhaproxy_docker.png)](docs/docker.md) -[![Static](docs/easyhaproxy_static.png)](docs/static.md) - -Or you can install using tools: - -[![Helm](docs/easyhaproxy_helm.png)](docs/helm.md) -[![MicroK8s](docs/easyhaproxy_microk8s.png)](docs/microk8s.md) -[![Dokku](docs/easyhaproxy_dokku.png)](docs/dokku.md) -[![DigitalOcean](docs/easyhaproxy_digitalocean.png)](docs/digitalocean.md) - -## Special Topics - -If you already set up the EasyHAProxy, is time to go deeper: - -- [Custom SSL](docs/ssl.md) -- [Automatic Certificate Issuing](docs/acme.md) (e.g. Letsencrypt) - -## Configuration Reference - -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 - -## Development - -### Requirements - -- Python 3.11 or higher -- [uv](https://github.com/astral-sh/uv) package manager - -### Installation for Development - -```bash -# Install uv (if not already installed) -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Clone the repository -git clone https://github.com/byjg/docker-easy-haproxy.git -cd docker-easy-haproxy - -# Install dependencies (creates virtual environment automatically) -uv sync --dev - -# Run tests -make test -# or directly: uv run pytest tests/ -vv - -# Run linting -make lint - -# Format code -make format -``` - -### Installing the Package - -```bash -# Install with uv -uv pip install easymapping - -# Or install from source -uv pip install -e ".[dev]" -``` +- [Dokku](docs/guides/dokku.md) +- [MicroK8s](docs/guides/microk8s.md) +- [DigitalOcean Marketplace](docs/guides/digitalocean.md) ## See EasyHAProxy in action @@ -154,6 +69,5 @@ Click on the image to see the videos (use HD for better visualization) [Here is the code](https://gist.github.com/byjg/e125e478a0562190176d69ea795fd3d4) applied in the test examples above. - ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md index 5030bba..5bcb0a2 100644 --- a/deploy/kubernetes/README.md +++ b/deploy/kubernetes/README.md @@ -27,7 +27,7 @@ kubectl apply -f easyhaproxy-nodeport.yml kubectl apply -f easyhaproxy-clusterip.yml ``` -For more details, see the [Kubernetes documentation](../../docs/kubernetes.md). +For more details, see the [Kubernetes documentation](../../docs/getting-started/kubernetes.md). ## Regenerating These Files diff --git a/docs/Plugins/jwt-validator.md b/docs/Plugins/jwt-validator.md deleted file mode 100644 index 9cd4db8..0000000 --- a/docs/Plugins/jwt-validator.md +++ /dev/null @@ -1,385 +0,0 @@ ---- -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 (priority 1: explicit file path) | (optional) | -| `pubkey` | Public key content as base64-encoded string (priority 2: inline content) | (optional) | -| `k8s_secret.pubkey` | Kubernetes secret containing public key (priority 3: Kubernetes only - see below) | (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` | - -### Public Key Configuration Priority - -When multiple public key options are configured, they are evaluated in this order: -1. **`pubkey_path`** - Direct file path (explicit configuration) -2. **`pubkey`** - Base64-encoded key content (inline configuration) -3. **`k8s_secret.pubkey`** - Kubernetes secret (recommended for Kubernetes deployments) - -The first configured option is used; others are ignored. - -## 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/easyhaproxy/jwt_keys/api_pubkey.pem - volumes: - - ./pubkey.pem:/etc/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/jwt_keys/api_pubkey.pem - easyhaproxy.http.plugin.jwt_validator.allow_anonymous: true - volumes: - - ./pubkey.pem:/etc/easyhaproxy/jwt_keys/api_pubkey.pem:ro -# Requests without Authorization header are allowed -# Requests with Authorization header are validated -# Invalid JWTs are rejected -``` - -### Kubernetes with Secrets (Recommended) - -The recommended way to configure JWT public keys in Kubernetes is using Kubernetes Secrets with the `k8s_secret` pattern: - -```yaml ---- -# Create a secret with your JWT public key -apiVersion: v1 -kind: Secret -metadata: - name: jwt-pubkey-secret - namespace: production -type: Opaque -stringData: - pubkey: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY----- - ---- -# Reference it in your ingress -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: api-ingress - namespace: production - 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" - # Load public key from Kubernetes secret - easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret" - easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" - easyhaproxy.plugin.jwt_validator.only_paths: "false" -spec: - ingressClassName: easyhaproxy - rules: - - host: api.example.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: api-service - port: - number: 8080 -``` - -**With explicit secret key name:** - -```yaml -metadata: - annotations: - # Use custom key name from the secret - easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret/rsa-public-key" -``` - -For complete details about the `k8s_secret` pattern, including auto-detect vs explicit key names, troubleshooting, and security considerations, see: [Loading Plugin Configuration from Kubernetes Secrets](../kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets) - -### Kubernetes with pubkey_path (Legacy) - -You can also mount the public key file using ConfigMaps or volumes: - -```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/easyhaproxy/jwt_keys/api_pubkey.pem" - easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" - easyhaproxy.plugin.jwt_validator.only_paths: "false" -spec: - ingressClassName: easyhaproxy - rules: - - host: api.example.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: api-service - port: - number: 8080 -``` - -**Note:** This requires mounting the public key file into the EasyHAProxy pod using ConfigMaps or volumes. Using `k8s_secret.pubkey` is recommended as it's simpler and more secure. - -### Static YAML Configuration - -```yaml -# /etc/easyhaproxy/static/config.yaml -containers: - "api.example.com:443": - ip: ["api-service:8080"] - ssl: true - plugins: [jwt_validator] - plugin: - jwt_validator: - algorithm: RS256 - issuer: https://auth.example.com/ - audience: https://api.example.com - pubkey_path: /etc/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/concepts/_category_.json b/docs/concepts/_category_.json new file mode 100644 index 0000000..1d3167d --- /dev/null +++ b/docs/concepts/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Concepts", + "position": 3 +} diff --git a/docs/concepts/index.md b/docs/concepts/index.md new file mode 100644 index 0000000..f5d253e --- /dev/null +++ b/docs/concepts/index.md @@ -0,0 +1,103 @@ +--- +sidebar_position: 1 +sidebar_label: "Concepts" +--- + +# Concepts + +This section explains how EasyHAProxy works under the hood — the mental models that help you understand why things are configured the way they are. + +## Service Discovery + +EasyHAProxy runs a polling loop every N seconds (default 10, configurable via `EASYHAPROXY_REFRESH_CONF`). On each tick it: + +1. **Queries your runtime** — Docker API for containers/services, Kubernetes API for Ingress objects, or reads the static YAML file. +2. **Filters by label/annotation prefix** — only resources that carry the `easyhaproxy` prefix (or your custom `EASYHAPROXY_LABEL_PREFIX`) are considered. +3. **Builds an intermediate structure** — an in-memory list of (host, port, backend) tuples called `easymapping`. +4. **Runs plugins** — global plugins run once; domain plugins run once per host entry. +5. **Renders `haproxy.cfg`** from a Jinja2 template using the `easymapping` data. +6. **Reloads HAProxy** if the rendered config differs from the previous one (zero-downtime reload). + +### Discovery mode comparison + +| Mode | Source polled | Auth required | +|------|--------------|---------------| +| `docker` | Docker socket `/var/run/docker.sock` | Socket access | +| `swarm` | Docker socket (Swarm API) | Socket access | +| `kubernetes` | Kubernetes API server | RBAC (`get`/`list` on Ingress, Secret) | +| `static` | File on disk | None | + +## Configuration Pipeline + +``` +Labels / Annotations / YAML file + ↓ + Discovery (Docker / Swarm / K8s / Static) + ↓ + parsed_object { IP → label map } + ↓ + [GLOBAL PLUGINS] ← executed once + ↓ + easymapping [ list of domain configs ] + ↓ + For each domain: + [DOMAIN PLUGINS] ← executed per domain + ↓ + PluginResult snippets collected + ↓ + Jinja2 template render → haproxy.cfg + ↓ + HAProxy reload (if config changed) +``` + +The key insight: **you never write `haproxy.cfg` by hand**. You express intent through labels/annotations/YAML, and EasyHAProxy translates that into valid HAProxy configuration on every discovery cycle. + +## Plugin Execution Model + +Plugins are Python classes that implement `PluginInterface`. They are discovered automatically from: + +1. `/src/plugins/builtin/` — bundled plugins +2. `/etc/easyhaproxy/plugins/` — your custom plugins + +**Two execution types:** + +### GLOBAL plugins + +- Run **once per discovery cycle**, before any domain processing. +- Receive the full `parsed_object` (all discovered services). +- Use cases: cleanup tasks, global monitoring, DNS updates. +- Example: `cleanup` plugin. + +### DOMAIN plugins + +- Run **once per discovered domain/host**. +- Receive domain-specific context: domain name, port, label/annotation map. +- Return `PluginResult` containing HAProxy config snippets to inject into the backend section. +- Use cases: IP whitelisting, JWT validation, Cloudflare IP restoration, path blocking. +- Examples: `cloudflare`, `deny_pages`, `jwt_validator`, `ip_whitelist`, `fastcgi`. + +**Configuration precedence** (highest to lowest): +1. Container labels / Ingress annotations (per-domain) +2. Static YAML `plugins.config` block +3. Environment variables `EASYHAPROXY_PLUGIN__` + +See the [Plugin Developer Guide](../guides/plugin-development.md) for how to build your own plugin. + +## SSL/TLS Termination Model + +SSL termination happens **at HAProxy**, not in your backend containers. + +``` +Internet → HAProxy (TLS decryption) → Backend container (plain HTTP) +``` + +Your containers should only serve HTTP on their internal port. HAProxy handles all TLS on ports 80/443. + +**Certificate sources** (evaluated in this order for each domain): +1. **ACME/Certbot** — if `certbot=true` label is set, EasyHAProxy runs Certbot HTTP-01 challenge automatically. +2. **Volume-mounted PEM** — files in `/etc/easyhaproxy/certs/haproxy/{domain}.pem`. +3. **Label-embedded PEM** — base64 certificate in the `sslcert` label. + +Both ACME and manual certificates can coexist. Per domain, whichever source is configured takes precedence as shown above. + +See [SSL setup](../guides/ssl.md) and [ACME/Let's Encrypt](../guides/acme.md) for step-by-step configuration. diff --git a/docs/limitations.md b/docs/concepts/limitations.md similarity index 97% rename from docs/limitations.md rename to docs/concepts/limitations.md index 9d3bba7..5dd1fa8 100644 --- a/docs/limitations.md +++ b/docs/concepts/limitations.md @@ -1,5 +1,6 @@ --- -sidebar_position: 23 +sidebar_position: 2 +sidebar_label: "Limitations" --- # Limitations and Considerations diff --git a/docs/docker.md b/docs/docker.md deleted file mode 100644 index 508df98..0000000 --- a/docs/docker.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -sidebar_position: 3 ---- - -# Docker - -## Setup Docker EasyHAProxy - -This method involves using a standalone Docker installation to discover containers -and configure HAProxy. - -EasyHAProxy inspects Docker containers and retrieves labels to configure HAProxy. -Once it identifies a container with at least the label 'easyhaproxy.http.host,' -it configures HAProxy to redirect traffic to that container. -To accomplish this, EasyHAProxy may need to attach the same network to its container. - -It's recommended to create a network external to EasyHAProxy, although it's not mandatory. - -:::warning Limitations -- You cannot mix Docker containers with Swarm containers. -- This method does not work with containers that use the `--network=host` option. See [limitations](limitations.md) for details. -::: - -For example: - -```bash title="Create EasyHAProxy network" -docker network create easyhaproxy -``` - -And then run the EasyHAProxy: - -```bash title="Run EasyHAProxy container" -docker run -d \ - --name easy-haproxy-container \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -e EASYHAPROXY_DISCOVER="docker" \ - # + Environment Variables \ - -p 80:80 \ - -p 443:443 \ - -p 1936:1936 \ - --network easyhaproxy - byjg/easy-haproxy -``` - -Mapping to `/var/run/docker.sock` is necessary to discover the docker containers and get the labels; - -## Running containers - -To make your containers "discoverable" by EasyHAProxy, this is the minimum configuration you need: - -```bash title="Run container with EasyHAProxy labels" -docker run -d \ - --label easyhaproxy.http.host=example.org \ - --label easyhaproxy.http.port=80 \ - --label easyhaproxy.http.localport=8080 \ - --network easyhaproxy - my/image:tag -``` - -Once the container is running, EasyHAProxy will detect automatically and start to redirect all traffic from `example.org:80` to your container. - -You don't need to expose any port in your container. - -Please follow the [docker label configuration](container-labels.md) to see other configurations available. - -## Setup the EasyHAProxy container - -You can configure the behavior of the EasyHAProxy by setup specific environment variables. To get a list of the variables, please follow the [environment variable guide](environment-variable.md) - -## Setup certificates with ACME (e.g. Letsencrypt) - -Follow [this link](acme.md) - -## Setup your own certificates - -Follow [this link](ssl.md) - ----- -[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/getting-started/_category_.json b/docs/getting-started/_category_.json new file mode 100644 index 0000000..3562d43 --- /dev/null +++ b/docs/getting-started/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Getting Started", + "position": 1 +} diff --git a/docs/getting-started/docker.md b/docs/getting-started/docker.md new file mode 100644 index 0000000..ef8b95c --- /dev/null +++ b/docs/getting-started/docker.md @@ -0,0 +1,66 @@ +--- +sidebar_position: 3 +sidebar_label: "Docker" +--- + +# Docker + +EasyHAProxy inspects running Docker containers, reads their labels, and configures HAProxy automatically. + +:::warning Limitations +- You cannot mix Docker containers with Swarm containers. +- This method does not work with containers that use the `--network=host` option. See [limitations](../concepts/limitations.md) for details. +::: + +## Step 1 — Create a shared network + +```bash +docker network create easyhaproxy +``` + +It's recommended to use an external network so EasyHAProxy and your app containers can communicate. + +## Step 2 — Run EasyHAProxy + +```bash +docker run -d \ + --name easy-haproxy-container \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e EASYHAPROXY_DISCOVER="docker" \ + -p 80:80 \ + -p 443:443 \ + -p 1936:1936 \ + --network easyhaproxy \ + byjg/easy-haproxy +``` + +Mounting `/var/run/docker.sock` is required so EasyHAProxy can query the Docker API. + +## Step 3 — Label your container + +```bash +docker run -d \ + --label easyhaproxy.http.host=example.org \ + --label easyhaproxy.http.port=80 \ + --label easyhaproxy.http.localport=8080 \ + --network easyhaproxy \ + my/image:tag +``` + +EasyHAProxy detects this container automatically and routes traffic from `example.org:80` to port 8080 in your container. You do not need to expose any container ports. + +## Step 4 — Verify + +Open `http://example.org` in your browser (or `curl http://example.org`). Traffic should reach your container. + +--- + +## Full options + +- [Container label reference](../reference/container-labels.md) — all available labels +- [Environment variable reference](../reference/environment-variables.md) — configure EasyHAProxy behavior +- [SSL certificates](../guides/ssl.md) — add custom TLS +- [ACME / Let's Encrypt](../guides/acme.md) — automatic certificate issuing + +---- +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..614bf78 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,51 @@ +--- +sidebar_position: 1 +sidebar_label: "Getting Started" +--- + +# Getting Started with EasyHAProxy + +EasyHAProxy dynamically builds `haproxy.cfg` from metadata on your running workloads — no HAProxy knowledge required. + +## Choose your runtime + +EasyHAProxy runs in two ways: + +| Runtime | When to choose | +|---------|---------------| +| **Docker container** (`byjg/easy-haproxy`) | You already run Docker, Swarm, or Kubernetes | +| **Native host** (`easy-haproxy` CLI) | You want HAProxy on the host with full OS control — no Docker needed | + +## Choose your discovery mode + +Once running, EasyHAProxy discovers your services in one of four ways: + +| Mode | How it works | Label/annotation format | +|------|-------------|------------------------| +| **Docker** | Reads labels from running containers on a Docker host | Container labels | +| **Swarm** | Reads labels from services in a Docker Swarm cluster | Service labels | +| **Kubernetes** | Reads `ingressClassName: easyhaproxy` Ingress resources | Ingress annotations | +| **Static** | Reads a hand-written YAML file you provide | YAML file | + +## Quick-start guides + +Pick the guide that matches your environment: + +### Container runtimes (Docker image) + +- **[Docker](docker.md)** — standalone Docker host, container labels +- **[Docker Swarm](swarm.md)** — overlay network, service labels +- **[Kubernetes](kubernetes.md)** — Ingress controller, DaemonSet/NodePort +- **[Static YAML](static.md)** — any environment, config file + +### Native host (pip/uv package) + +- **[Native install](native.md)** — HAProxy on the host, `easy-haproxy` CLI + +## What's next? + +After you have traffic flowing: + +- **[Guides](../guides/ssl.md)** — SSL certificates, ACME/Let's Encrypt, plugins +- **[Concepts](../concepts/index.md)** — how service discovery and the config pipeline work +- **[Reference](../reference/environment-variables.md)** — full environment variable and label tables diff --git a/docs/getting-started/kubernetes.md b/docs/getting-started/kubernetes.md new file mode 100644 index 0000000..84e39d4 --- /dev/null +++ b/docs/getting-started/kubernetes.md @@ -0,0 +1,256 @@ +--- +sidebar_position: 1 +sidebar_label: "Kubernetes" +--- + +# Kubernetes + +EasyHAProxy acts as an Ingress controller for Kubernetes — it watches Ingress resources with `ingressClassName: easyhaproxy` and configures HAProxy automatically. + +:::info How it works +EasyHAProxy queries all ingress definitions with either the +`spec.ingressClassName: easyhaproxy` field (recommended) or the deprecated annotation +`kubernetes.io/ingress.class: easyhaproxy-ingress` (for backward compatibility). +::: + +## Deployment Modes + +Choose the deployment mode that fits your infrastructure: + +| Mode | Workload | Exposed Ports | Node Label | Recommended When | +|-----------|------------|------------------------|------------|-------------------------------| +| NodePort | Deployment | 31080 / 31443 / 31936 | No | Most setups **(recommended)** | +| ClusterIP | Deployment | cluster-internal only | No | Behind a LoadBalancer **(recommended)** | +| DaemonSet | DaemonSet | 80 / 443 / 1936 (host) | Yes | Bare-metal, special cases | + +**NodePort** and **ClusterIP** run as a Deployment — no node label needed, and they survive node replacements safely. + +**DaemonSet** binds to host ports and requires a node label (`easyhaproxy/node=master`) via `nodeAffinity`. The label must be manually reapplied after any node replacement, which can cause outages. Use only for bare-metal or special-case setups. + +EasyHAProxy detects its deployment mode automatically (`EASYHAPROXY_DEPLOYMENT_MODE=auto`). See the [environment variable reference](../reference/environment-variables.md#kubernetes) for manual override options. + +## Step 1 — Install EasyHAProxy + +```bash +kubectl create namespace easyhaproxy +``` + +### NodePort (recommended) + +Exposes HAProxy on NodePort `31080` (HTTP), `31443` (HTTPS), and `31936` (stats). Point your DNS or external load balancer to any node IP on these ports. + +```bash +kubectl apply -f \ + https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.0.0/deploy/kubernetes/easyhaproxy-nodeport.yml +``` + +### ClusterIP (behind a LoadBalancer) + +Cluster-internal only. Pair with an external cloud LoadBalancer or `kubectl port-forward` for local testing. + +```bash +kubectl apply -f \ + https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.0.0/deploy/kubernetes/easyhaproxy-clusterip.yml +``` + +### DaemonSet (special cases — requires node label) + +:::warning Requires node label maintenance +The node label must be reapplied after any node replacement. Failing to do so will cause an outage. +::: + +```bash +# Label the target node first +kubectl label nodes node-01 "easyhaproxy/node=master" + +kubectl apply -f \ + https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.0.0/deploy/kubernetes/easyhaproxy-daemonset.yml +``` + +If you need to configure environment variables (log levels, stats password, etc.), see the [environment variable reference](../reference/environment-variables.md). + +## Step 2 — Create an Ingress + +```yaml +kind: Ingress +metadata: + name: example-ingress + namespace: example +spec: + ingressClassName: easyhaproxy + rules: + - host: example.org + http: + paths: + - backend: + service: + name: example-service + port: + number: 8080 + pathType: ImplementationSpecific +``` + +EasyHAProxy routes traffic from `example.org:80` to your service at port 8080. No container port exposure needed. + +:::note Backward Compatibility +The deprecated annotation `kubernetes.io/ingress.class: easyhaproxy-ingress` is still supported but `spec.ingressClassName` is recommended for new deployments. +::: + +## Step 3 — Verify + +```bash +curl http://example.org +``` + +--- + +## Kubernetes Annotations + +Customize EasyHAProxy behavior per-ingress using annotations. For the full annotations reference, see [Container Labels — Kubernetes Ingress Annotations](../reference/container-labels.md#kubernetes-ingress-annotations). + +**Important**: Annotations apply to all hosts in the ingress configuration. + +## Using Plugins + +Add the `easyhaproxy.plugins` annotation with a comma-separated list of plugin names: + +```yaml +metadata: + annotations: + easyhaproxy.plugins: "cloudflare,deny_pages" + easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php" + easyhaproxy.plugin.deny_pages.status_code: "404" +``` + +For full plugin documentation, see the [Using Plugins](../guides/plugins.md) guide. + +## Loading Plugin Configuration from Kubernetes Secrets + +EasyHAProxy supports loading sensitive plugin configuration values from Kubernetes Secrets using the `k8s_secret` pattern: + +```yaml +# Auto-detect key (tries common variations): +easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name" + +# Explicit key (no variations): +easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name/key_name" +``` + +### How It Works + +1. You create a Kubernetes Secret with your sensitive data +2. You reference the secret in your ingress annotation using the `k8s_secret` pattern +3. EasyHAProxy reads the secret from the same namespace as the ingress +4. EasyHAProxy transforms the annotation to inject the secret value +5. The plugin receives the value as if it was provided directly in the annotation + +### Complete Example + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: jwt-pubkey-secret + namespace: production +type: Opaque +stringData: + pubkey: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY----- + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: api-ingress + namespace: production + 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.k8s_secret.pubkey: "jwt-pubkey-secret" +spec: + ingressClassName: easyhaproxy + rules: + - host: api.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: api-service + port: + number: 8080 +``` + +### Priority Order + +1. **Explicit annotation** (e.g., `easyhaproxy.plugin.jwt_validator.pubkey: "value"`) +2. **k8s_secret annotation** (e.g., `easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret"`) + +### Security Considerations + +- Secrets are read from the **same namespace** as the ingress (no cross-namespace access) +- EasyHAProxy needs RBAC permissions to read secrets (included in default deployment) +- Use Kubernetes RBAC to control which service accounts can read which secrets + +## ACME / Let's Encrypt + +Add the `easyhaproxy.certbot` annotation to enable automatic certificate issuing: + +```yaml +kind: Ingress +metadata: + annotations: + easyhaproxy.certbot: 'true' + name: example-ingress + namespace: example +spec: + ingressClassName: easyhaproxy +``` + +More info in the [ACME guide](../guides/acme.md). Make sure ports 80 and 443 are publicly reachable. + +## Custom SSL Certificates + +Create a secret with your certificate and key and associate it with your ingress: + +```yaml +--- +apiVersion: v1 +kind: Secret +metadata: + name: host2-tls + namespace: default +data: + tls.crt: base64 of your certificate + tls.key: base64 of your certificate private key +type: kubernetes.io/tls + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: tls-example + namespace: default +spec: + ingressClassName: easyhaproxy + tls: + - hosts: + - host2.local + secretName: host2-tls + rules: + ... +``` + +## Important Limitations + +- The implementation doesn't support all ingress properties or wildcard domains. +- EasyHAProxy reads all `spec.rules[].host` values but parses only the **first path** per rule. + +---- +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/getting-started/native.md b/docs/getting-started/native.md new file mode 100644 index 0000000..22634c5 --- /dev/null +++ b/docs/getting-started/native.md @@ -0,0 +1,130 @@ +--- +sidebar_position: 5 +sidebar_label: "Native (pip/uv)" +--- + +# Native install (pip / uv) + +EasyHAProxy can run directly on any Linux or macOS host without Docker, using the `easyhaproxy` Python package. HAProxy is installed on the host; EasyHAProxy manages it. + +## Prerequisites + +HAProxy must be installed and available in your system `PATH` before running `easy-haproxy`. + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + + + +```bash +sudo apt install haproxy +``` + + + + +```bash +sudo dnf install haproxy +``` + + + + +```bash +brew install haproxy +``` + + + + +## Installation + +### Recommended: `uv tool` (system-wide, isolated) + +[`uv`](https://docs.astral.sh/uv/) installs `easyhaproxy` into its own isolated environment and exposes the `easy-haproxy` binary in `~/.local/bin/`. + +```bash +# Install uv (if not already installed) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install easyhaproxy as a tool +uv tool install easyhaproxy + +# Make sure ~/.local/bin is in PATH (one-time setup) +uv tool update-shell +``` + +### Alternative: `pip` + +```bash +pip install easyhaproxy +``` + +:::note Virtual environments +When installing inside a virtual environment, `easy-haproxy` is only available while the environment is activated. For system-wide use, prefer `uv tool install`. +::: + +## Quick start + +### Static mode (bare-metal / VM) + +```bash +mkdir -p ~/easyhaproxy/static + +cat > ~/easyhaproxy/static/config.yml <.certbot=true` to the service you want a certificate for. - - Ensure the service is exposed on HTTP port 80 from EasyHAProxy’s perspective (e.g., `easyhaproxy..port=80`). ACME HTTP-01 will not work if the front port is not 80. + - Ensure the service is exposed on HTTP port 80 from EasyHAProxy's perspective (e.g., `easyhaproxy..port=80`). ACME HTTP-01 will not work if the front port is not 80. - Provide the domain via `easyhaproxy..host=yourdomain.tld` (and additional labels per your install method). What happens under the hood @@ -90,7 +91,7 @@ Possible values for: `EASYHAPROXY_CERTBOT_AUTOCONFIG` | Entrust | - | No | Yes | Yes | Yes | [Link](https://www.entrust.com/knowledgebase/ssl/how-to-use-acme-to-install-ssl-tls-certificates-in-entrust-certificate-services-apache) | | Sectigo | - | No | Yes | Yes | Yes | [Link](https://www.sectigo.com/resource-library/sectigos-acme-automation) | -This configuration is global. After set up ACME properly, is necessary enable for each domain the certificate request. +This configuration is global. After set up ACME properly, is necessary enable for each domain the certificate request. To do that add the label: `easyhaproxy..certbot=true`. See the method of installation you are using to learn how to set up properly. @@ -123,10 +124,10 @@ To avoid hitting rate limits and certificate issuing problems: - **You must persist** the container folder `/etc/easyhaproxy/certs/certbot` outside the container - **Never delete or modify** its contents manually - If you don't persist this folder, or if you delete/modify its contents, certificate issuing may not work properly and you may hit rate limits -::: +::: If you are using Let's Encrypt, be aware of it rate limits: - + - https://letsencrypt.org/docs/duplicate-certificate-limit/ - https://letsencrypt.org/docs/rate-limits/ @@ -172,10 +173,6 @@ services: EASYHAPROXY_CERTBOT_EMAIL: your-email@example.com EASYHAPROXY_CERTBOT_AUTOCONFIG: letsencrypt - # ACME/Certbot Configuration (Method 2: Manual) - # EASYHAPROXY_CERTBOT_EMAIL: your-email@example.com - # EASYHAPROXY_CERTBOT_SERVER: https://acme-v02.api.letsencrypt.org/directory - # Other settings EASYHAPROXY_SSL_MODE: "default" HAPROXY_CUSTOMERRORS: "true" @@ -267,28 +264,5 @@ If you hit Let's Encrypt rate limits: - Ensure `/etc/easyhaproxy/certs/certbot` volume is properly persisted - See: https://letsencrypt.org/docs/rate-limits/ -### Using Both ACME and Manual Certificates - -You can use both simultaneously: -1. Mount both volumes (`certs_certbot` and `certs_haproxy`) -2. Use `certbot=true` label for domains that should use ACME -3. Omit the label for domains using manual certificates - -Example: -```yaml -services: - # This service uses ACME - app1: - labels: - easyhaproxy.http.host: auto.example.com - easyhaproxy.http.certbot: "true" - - # This service uses manual certificate - app2: - labels: - easyhaproxy.http.host: manual.example.com - # No certbot label - will use /etc/easyhaproxy/certs/haproxy/manual.example.com.pem -``` - ---- -[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/digitalocean.md b/docs/guides/digitalocean.md similarity index 83% rename from docs/digitalocean.md rename to docs/guides/digitalocean.md index aab938f..2fda304 100644 --- a/docs/digitalocean.md +++ b/docs/guides/digitalocean.md @@ -1,15 +1,15 @@ --- sidebar_position: 9 +sidebar_label: "DigitalOcean" --- # DigitalOcean DigitalOcean is a cloud infrastructure provider focused on simplifying web infrastructure for software developers. -EasyHAProxy is available on DigitalOcean Marketplace. +EasyHAProxy is available on DigitalOcean Marketplace. You can install it with a few clicks directly from the DigitalOcean dashboard. ## Installing EasyHAProxy on DigitalOcean Please refer the [EasyHAProxy page on DigitalOcean Marketplace](https://marketplace.digitalocean.com/apps/easyhaproxy-ingress-controller). - diff --git a/docs/dokku.md b/docs/guides/dokku.md similarity index 94% rename from docs/dokku.md rename to docs/guides/dokku.md index c995b5d..e0b290d 100644 --- a/docs/dokku.md +++ b/docs/guides/dokku.md @@ -1,5 +1,6 @@ --- sidebar_position: 8 +sidebar_label: "Dokku" --- # Dokku diff --git a/docs/guides/helm.md b/docs/guides/helm.md new file mode 100644 index 0000000..847ea64 --- /dev/null +++ b/docs/guides/helm.md @@ -0,0 +1,50 @@ +--- +sidebar_position: 6 +sidebar_label: "Helm" +--- + +# Helm 3 + +Helm is a package manager for Kubernetes. It allows you to install and manage applications on Kubernetes. + +## Setup EasyHAProxy with Helm 3 + +### 1) Install EasyHAProxy + +```bash title="Add the Helm repository" +helm repo add byjg https://opensource.byjg.com/helm +helm repo update byjg +kubectl create namespace easyhaproxy +``` + +```bash title="Install with Helm" +helm upgrade --install ingress byjg/easyhaproxy \ + --namespace easyhaproxy \ + --set resources.requests.cpu=100m \ + --set resources.requests.memory=128Mi +``` + +### 2) Choose a deployment mode + +By default, EasyHAProxy installs as a **DaemonSet** (`service.create: false`). To use the recommended **NodePort** or **ClusterIP** modes instead, set `service.create: true`: + +```bash title="NodePort (recommended)" +helm upgrade --install ingress byjg/easyhaproxy \ + --namespace easyhaproxy \ + --set service.create=true \ + --set service.type=NodePort +``` + +```bash title="ClusterIP (behind LoadBalancer)" +helm upgrade --install ingress byjg/easyhaproxy \ + --namespace easyhaproxy \ + --set service.create=true \ + --set service.type=ClusterIP +``` + +See [Deployment Modes](../getting-started/kubernetes.md#deployment-modes) for a comparison of all three modes. + +For the complete list of configurable values, see the [Helm Values reference](../reference/helm.md). + +---- +[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/microk8s.md b/docs/guides/microk8s.md similarity index 93% rename from docs/microk8s.md rename to docs/guides/microk8s.md index 7326a33..05d1f8c 100644 --- a/docs/microk8s.md +++ b/docs/guides/microk8s.md @@ -1,5 +1,6 @@ --- sidebar_position: 7 +sidebar_label: "MicroK8s" --- # Microk8s Add-ons @@ -74,7 +75,7 @@ microk8s enable easyhaproxy --nodeport You need to disable any ingress controller you have previously installed (e.g., nginx, traefik, etc.) before installing EasyHAProxy to avoid conflicts. ::: -For more parameters you can refer to the [Kubernetes](kubernetes.md) page. +For more parameters you can refer to the [Kubernetes](../getting-started/kubernetes.md) page. ---- [Open source ByJG](http://opensource.byjg.com) diff --git a/docs/guides/plugin-development.md b/docs/guides/plugin-development.md new file mode 100644 index 0000000..8943561 --- /dev/null +++ b/docs/guides/plugin-development.md @@ -0,0 +1,695 @@ +--- +sidebar_position: 4 +sidebar_label: "Plugin Development" +--- + +# 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. INITIALIZE PHASE + ├─ Calls plugin.initialize() for each plugin + ├─ Plugins request file system resources (directories, files) + ├─ PluginManager processes resource requests + └─ Creates directories and files as needed + +4. EXECUTION PHASE (per discovery cycle) + ├─ GLOBAL PLUGINS + │ └─ Executes all global plugins once + │ + └─ DOMAIN PLUGINS + └─ For each discovered domain: + └─ Executes all domain plugins + +5. RESULT PROCESSING + ├─ Collects PluginResult from each plugin + ├─ Injects haproxy_config into backend sections + ├─ Injects global_configs into global section + ├─ Injects defaults_configs into defaults section + ├─ 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/easyhaproxy/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/easyhaproxy/plugins/` (or builtin location for core plugins): + +```python +# /etc/easyhaproxy/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 logger_easyhaproxy + + +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/easyhaproxy/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 + + def initialize(self) -> InitializationResult: + """ + Initialize plugin resources (new in v2.0) + + Optional method to request file system resources. + Default implementation returns empty result (no-op). + + Returns: + InitializationResult with resource requests + """ + return InitializationResult() + + @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 +- `initialize()` - **[New in v2.0]** Request file system resources (optional) +- `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 +``` + +### 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 + global_configs: list[str] = field(default_factory=list) # [New] Global-level configs + defaults_configs: list[str] = field(default_factory=list) # [New] Defaults-level configs +``` + +**Fields:** + +- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend) +- `modified_easymapping` - Modified easymapping structure (optional, advanced use) +- `metadata` - Dictionary with debugging/logging information +- `global_configs` - **[New in v2.0]** List of global-level HAProxy configs (e.g., fcgi-app definitions) +- `defaults_configs` - **[New in v2.0]** List of defaults-level HAProxy configs (e.g., log-format) + +### ResourceRequest + +**[New in v2.0]** Request for file system resources during plugin initialization. + +```python +@dataclass +class ResourceRequest: + """Request for file system resources""" + resource_type: str # "directory" or "file" + path: str + content: str | None = None + overwrite: bool = False +``` + +### InitializationResult + +**[New in v2.0]** Plugin initialization result with resource requests. + +```python +@dataclass +class InitializationResult: + """Plugin initialization result with resource requests""" + resources: list[ResourceRequest] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) +``` + +--- + +## Advanced Examples + +### Example 1: IP Whitelist Plugin (DOMAIN) + +Restrict access to specific IP addresses per domain. + +```python +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 + + +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: + 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: + if not self.enabled or not self.allowed_ips: + return PluginResult() + + ips_str = " ".join(self.allowed_ips) + + 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, + metadata={ + "domain": context.domain, + "allowed_ips": self.allowed_ips, + "status_code": self.status_code + } + ) +``` + +### Example 2: Cleanup Plugin (GLOBAL) + +Perform cleanup tasks during each discovery cycle. + +```python +import os +import sys +import glob +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from plugins import PluginInterface, PluginType, PluginContext, PluginResult +from functions import logger_easyhaproxy + + +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: + 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: + logger_easyhaproxy.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: + if not self.enabled: + return PluginResult() + + cleanup_actions = [] + + 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: + 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}") + except Exception as e: + logger_easyhaproxy.warning(f"Failed to remove temp file {filepath}: {e}") + except Exception as e: + logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") + + return PluginResult( + haproxy_config="", + metadata={ + "actions_performed": len(cleanup_actions), + "actions": cleanup_actions + } + ) +``` + +--- + +## Best Practices + +1. **Use `initialize()` for resource setup** - Request directories/files during init, not in `process()` +2. **Use typed result fields** - Use `global_configs` and `defaults_configs` instead of metadata for config injection +3. **Handle errors gracefully** - Use try/except and return `PluginResult()` on error +4. **Validate in `configure()`** - Don't validate at `process()` time +5. **Use metadata for debugging** - Include useful info in `metadata` dict +6. **Support multiple boolean formats** - `str(config["enabled"]).lower() in ["true", "1", "yes"]` +7. **Support list and string formats** - Handle both YAML lists and comma-separated strings +8. **Use descriptive names** - Clear plugin name, ACL names, and config keys +9. **Document your plugin** - Include docstring with YAML and label examples +10. **Return empty result when disabled** - Check `self.enabled` first + +--- + +## Testing Guidelines + +### Unit Testing + +```python +from plugins import PluginContext +from plugins.builtin.my_plugin import MyPlugin + + +class TestMyPlugin: + def test_plugin_initialization(self): + plugin = MyPlugin() + assert plugin.name == "my_plugin" + assert plugin.enabled is True + + def test_plugin_generates_config(self): + 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 "X-My-Header test_value" in result.haproxy_config + + def test_plugin_disabled(self): + plugin = MyPlugin() + plugin.configure({"enabled": "false"}) + + context = PluginContext( + parsed_object={}, easymapping=[], container_env={}, domain="example.com" + ) + + result = plugin.process(context) + assert result.haproxy_config == "" +``` + +--- + +## Troubleshooting + +### Plugin Not Loading + +1. File not in plugins directory: `ls -la /etc/easyhaproxy/plugins/` +2. Invalid Python syntax: `python3 -m py_compile /etc/easyhaproxy/plugins/my_plugin.py` +3. Class doesn't inherit `PluginInterface` +4. Missing required imports + +### Plugin Not Executing + +1. Plugin not enabled in configuration +2. Wrong plugin type for use case +3. Plugin disabled via configuration + +### HAProxy Configuration Invalid + +```bash +# Test configuration manually: +haproxy -c -f /etc/easyhaproxy/haproxy/haproxy.cfg +``` + +--- + +## Distribution + +### Option 1: Single File + +```bash +cp my_plugin.py /etc/easyhaproxy/plugins/ +``` + +### Option 2: Docker Image with Plugin + +```dockerfile +FROM byjg/easy-haproxy:latest +COPY my_plugin.py /app/src/plugins/builtin/ +``` + +### Contributing to EasyHAProxy + +1. Fork the repository +2. Add your plugin to `src/plugins/builtin/` +3. Add tests in `src/tests/test_plugins.py` +4. Add documentation in `docs/reference/plugins/` +5. Create a pull request + +--- + +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 diff --git a/docs/plugins.md b/docs/guides/plugins.md similarity index 71% rename from docs/plugins.md rename to docs/guides/plugins.md index e7fce68..0cce5a1 100644 --- a/docs/plugins.md +++ b/docs/guides/plugins.md @@ -1,5 +1,6 @@ --- -sidebar_position: 15 +sidebar_position: 3 +sidebar_label: "Using Plugins" --- # Using Plugins @@ -44,12 +45,12 @@ Execute **once for each discovered domain/host**. 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 +- [Cloudflare](../reference/plugins/cloudflare.md) - Restore visitor IP from Cloudflare CDN +- [Cleanup](../reference/plugins/cleanup.md) - Cleanup temporary files +- [Deny Pages](../reference/plugins/deny-pages.md) - Block specific paths +- [IP Whitelist](../reference/plugins/ip-whitelist.md) - Restrict access to IPs/CIDR ranges +- [JWT Validator](../reference/plugins/jwt-validator.md) - Validate JWT tokens +- [FastCGI](../reference/plugins/fastcgi.md) - Configure PHP-FPM and FastCGI applications ## Configuration Methods @@ -96,7 +97,7 @@ spec: - Enable plugins: `easyhaproxy.plugins: plugin1,plugin2` - Configure plugin: `easyhaproxy.plugin..: value` -See the [Kubernetes guide](kubernetes.md#using-plugins-with-kubernetes) for more examples. +See the [Kubernetes guide](../getting-started/kubernetes.md) for more examples. ### 2. Container Labels (Docker/Docker Compose) @@ -181,11 +182,6 @@ EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS=true - 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 @@ -205,42 +201,8 @@ services: - ./auth_pubkey.pem:/etc/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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 @@ -262,8 +224,6 @@ labels: ### Cloudflare IP Restoration -Restore original visitor IPs for applications behind Cloudflare: - ```yaml labels: easyhaproxy.http.host: myapp.com @@ -272,8 +232,6 @@ labels: ### Multiple Plugins Together -Combine multiple plugins for one domain: - ```yaml labels: easyhaproxy.http.host: secure-app.com @@ -284,8 +242,6 @@ labels: ### Automatic Cleanup -Keep your system clean with automatic temp file removal: - ```yaml # /etc/easyhaproxy/static/config.yaml plugins: @@ -307,14 +263,6 @@ 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: @@ -324,19 +272,10 @@ 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 ``` @@ -357,18 +296,6 @@ DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/e 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:** @@ -383,16 +310,6 @@ For Docker deployments: 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 @@ -400,7 +317,6 @@ Per-ingress/per-container settings override global configuration. 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 @@ -416,7 +332,7 @@ Want to create your own plugins? See the [Plugin Developer Guide](plugin-develop ## 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 +- [Container Labels](../reference/container-labels.md) - Label configuration reference +- [Environment Variables](../reference/environment-variables.md) - Environment variable reference +- [Static Configuration](../getting-started/static.md) - YAML configuration reference +- [Kubernetes Guide](../getting-started/kubernetes.md) - Using plugins with Kubernetes diff --git a/docs/ssl.md b/docs/guides/ssl.md similarity index 98% rename from docs/ssl.md rename to docs/guides/ssl.md index cdca185..5f46a8b 100644 --- a/docs/ssl.md +++ b/docs/guides/ssl.md @@ -1,5 +1,6 @@ --- -sidebar_position: 10 +sidebar_position: 1 +sidebar_label: "Custom SSL Certificates" --- # Setup custom certificates diff --git a/docs/helm.md b/docs/helm.md deleted file mode 100644 index 055f938..0000000 --- a/docs/helm.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -sidebar_position: 6 ---- - -# Helm 3 - -Helm is a package manager for Kubernetes. It allows you to install and manage applications on Kubernetes. - -## Setup EasyHAProxy with Helm 3 - -### 1) Identify the node where your EasyHAProxy container will run - -:::warning Single Node Deployment -EasyHAProxy will be limited to a single node. To understand why, see the [limitations](limitations.md) page. -::: - -```bash title="List available nodes" -$ kubectl get nodes - -NAME STATUS ROLES AGE VERSION -node-01 Ready 561d v1.21.13-3 -node-02 Ready 561d v1.21.13-3 -``` - -Add the EasyHAProxy label to the node. - -```bash title="Label the node for EasyHAProxy" -kubectl label nodes node-01 "easyhaproxy/node=master" -``` - -### 2) Install EasyHAProxy - -Minimal configuration: - -```bash title="Install with Helm (minimal)" -helm repo add byjg https://opensource.byjg.com/helm -helm repo update byjg -kubectl create namespace easyhaproxy - -helm upgrade --install ingress byjg/easyhaproxy \ - --namespace easyhaproxy \ - --set resources.requests.cpu=100m \ - --set resources.requests.memory=128Mi -``` - -Customizing Helm Values: - -```yaml title="values.yaml" -easyhaproxy: - stats: - username: admin - password: password - refresh: "10" - customErrors: "true" - sslMode: loose - logLevel: - certbot: DEBUG - easyhaproxy: DEBUG - haproxy: DEBUG - certbot: - email: "" - -service: - create: false # If false, it will create a DaemonSet with hostPort. The easiest. - type: ClusterIP # or NodePort - annotations: {} - -binding: - ports: - http: 80 - https: 443 - stats: 1936 - additionalPorts: [] - -# Make sure to create this -masterNode: - label: easyhaproxy/node - values: - - master -``` - -For more parameters you can refer to the [Kubernetes](kubernetes.md) page. - ----- -[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/kubernetes.md b/docs/kubernetes.md deleted file mode 100644 index 3e09d8e..0000000 --- a/docs/kubernetes.md +++ /dev/null @@ -1,514 +0,0 @@ ---- -sidebar_position: 1 ---- - -# Kubernetes - -## Setup Kubernetes EasyHAProxy - -:::info How it works -EasyHAProxy for Kubernetes operates by querying all ingress definitions with either the -`spec.ingressClassName: easyhaproxy` field (recommended) or the deprecated annotation -`kubernetes.io/ingress.class: easyhaproxy-ingress` (for backward compatibility). Upon finding -a matching ingress class, EasyHAProxy immediately sets up HAProxy and begins serving traffic. -::: - -For Kubernetes installations, there are three available installation modes: -- DaemonSet: This mode exposes ports 80, 443, and 1936. -- NodePort: Ports 31080, 31443, and 31936 are exposed. -- ClusterIP: In this mode, no ports are exposed externally, and HAProxy is accessible only - within the cluster. - -To install EasyHAProxy in your Kubernetes cluster, follow these steps: - - -### 1) Identify the node where your EasyHAProxy container will run - -:::warning Single Node Deployment -EasyHAProxy will be limited to a single node. To understand why, see the [limitations](limitations.md) page. -::: - -```bash title="List available nodes" -$ kubectl get nodes - -NAME STATUS ROLES AGE VERSION -node-01 Ready 561d v1.21.13-3 -node-02 Ready 561d v1.21.13-3 -``` - -Add the EasyHAProxy label to the node. - -```bash title="Label the node for EasyHAProxy" -kubectl label nodes node-01 "easyhaproxy/node=master" -``` - -### 2) Install EasyHAProxy with Kubernetes Manifest - -```bash title="Install EasyHAProxy" -kubectl create namespace easyhaproxy - -kubectl apply -f \ - https://raw.githubusercontent.com/byjg/docker-easy-haproxy/6.0.0/deploy/kubernetes/easyhaproxy-daemonset.yml -``` - -If necessary, you can configure environment variables. To get a list of the variables, please follow the [environment variable guide](environment-variable.md) - -## Running containers - -Your container only requires creating an ingress with the `spec.ingressClassName: easyhaproxy` field pointing to your service. - -e.g. - -```yaml -kind: Ingress -metadata: - name: example-ingress - namespace: example -spec: - # Use ingressClassName (recommended) - ingressClassName: easyhaproxy - rules: - - host: example.org - http: - paths: - - backend: - service: - name: example-service - port: - number: 8080 - pathType: ImplementationSpecific -``` - -:::note Backward Compatibility -The deprecated annotation `kubernetes.io/ingress.class: easyhaproxy-ingress` is still supported for backward compatibility, but `spec.ingressClassName` is the recommended approach for new deployments. -::: - -Once the container is running, EasyHAProxy will detect automatically and start to redirect all traffic from `example.org:80` to your container at port 8080. - -You don't need to expose any port in your container. - -:::note Important Limitations -- The implementation doesn't support all ingress properties or wildcard domains at this time. -- The ingress will publish ports 80 and 443, plus 1936 if stats are enabled. -- EasyHAProxy will read all `spec.rules[].host` specifications, however it will parse only the **first path** `spec.rules[].http.paths[0].port.number` for each rule, and ignore the other paths. -::: - -## Kubernetes annotations - -| annotation | Description | Default | Example | -|-------------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------| -| kubernetes.io/ingress.class | (deprecated) Activate EasyHAProxy. Use `spec.ingressClassName` instead. | *optional* | 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: - easyhaproxy.plugins: "cloudflare,deny_pages" - name: example-ingress - namespace: example -spec: - ingressClassName: easyhaproxy - 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: - 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: - ingressClassName: easyhaproxy - 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: - 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/easyhaproxy/jwt_keys/api_pubkey.pem" -spec: - ingressClassName: easyhaproxy -``` - -**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: - 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: - ingressClassName: easyhaproxy -``` - -**Restore Cloudflare visitor IPs:** - -```yaml -metadata: - annotations: - easyhaproxy.plugins: "cloudflare" -spec: - ingressClassName: easyhaproxy -``` - -**Multiple plugins together:** - -```yaml -metadata: - annotations: - easyhaproxy.plugins: "cloudflare,deny_pages" - easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php" - easyhaproxy.plugin.deny_pages.status_code: "404" -spec: - ingressClassName: easyhaproxy -``` - -### 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. - -## Loading Plugin Configuration from Kubernetes Secrets - -EasyHAProxy supports loading sensitive plugin configuration values directly from Kubernetes Secrets using the `k8s_secret` pattern. This is a **generic, plugin-agnostic feature** that works with any plugin. - -### Why Use Kubernetes Secrets? - -- **Security**: Keep sensitive data (API keys, passwords, certificates) out of annotations -- **Best practices**: Follows Kubernetes conventions for managing sensitive data -- **Simplicity**: No need to mount volumes or ConfigMaps for secret data -- **Encryption**: Secrets are encrypted at rest in etcd - -### Annotation Format - -```yaml -# Auto-detect key (tries common variations): -easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name" - -# Explicit key (no variations): -easyhaproxy.plugin.{plugin_name}.k8s_secret.{config_key}: "secret_name/key_name" -``` - -### How It Works - -1. **You create** a Kubernetes Secret with your sensitive data -2. **You reference** the secret in your ingress annotation using the `k8s_secret` pattern -3. **EasyHAProxy reads** the secret from the same namespace as the ingress -4. **EasyHAProxy transforms** the annotation to inject the secret value -5. **The plugin receives** the value as if it was provided directly in the annotation - -**Example transformation:** - -```yaml -# Input annotation: -easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret" - -# EasyHAProxy reads the secret and transforms to: -easyhaproxy.plugin.jwt_validator.pubkey: "" -``` - -### Auto-Detect vs Explicit Key - -#### Auto-Detect Key Format - -When you use `"secret_name"` (without `/`), EasyHAProxy tries to find the key automatically: - -```yaml -easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret" -``` - -EasyHAProxy will try these keys in order: -1. Exact match: `pubkey` -2. Common variations based on the config key name - -**Auto-detect key variations:** - -| Config Key | Tries (in order) | -|------------|---------------------------------------| -| `pubkey` | `pubkey`, `public-key`, `jwt.pub`, `tls.crt` | -| `password` | `password`, `pass`, `pwd` | -| `api_key` | `api_key`, `apikey`, `api-key`, `key` | - -#### Explicit Key Format - -When you use `"secret_name/key_name"` (with `/`), EasyHAProxy only tries the exact key name: - -```yaml -easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret/rsa-public-key" -``` - -EasyHAProxy will **only** try: `rsa-public-key` (no variations) - -**Use explicit key when:** -- Your secret uses a non-standard key name -- You want to be explicit and avoid ambiguity -- Multiple keys exist in the secret - -### Complete Example - -```yaml ---- -# 1. Create a secret with your JWT public key -apiVersion: v1 -kind: Secret -metadata: - name: jwt-pubkey-secret - namespace: production -type: Opaque -stringData: - pubkey: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY----- - ---- -# 2. Reference it in your ingress -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: api-ingress - namespace: production - 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" - # Load pubkey from Kubernetes secret (auto-detect key) - easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-pubkey-secret" -spec: - ingressClassName: easyhaproxy - rules: - - host: api.example.com - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: api-service - port: - number: 8080 -``` - -### Example with Explicit Key Name - -```yaml ---- -apiVersion: v1 -kind: Secret -metadata: - name: app-credentials - namespace: production -type: Opaque -stringData: - # Custom key name - rsa-public-key: | - -----BEGIN PUBLIC KEY----- - ... - -----END PUBLIC KEY----- - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: api-ingress - namespace: production - annotations: - easyhaproxy.plugins: "jwt_validator" - # Use explicit key name after the slash - easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "app-credentials/rsa-public-key" -spec: - ingressClassName: easyhaproxy - # ... rest of configuration -``` - -### Using with Any Plugin - -The `k8s_secret` pattern works with **any plugin configuration**: - -```yaml -# JWT Validator - load public key -easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "jwt-secret" - -# Hypothetical API auth plugin - load API key -easyhaproxy.plugin.api_auth.k8s_secret.api_key: "api-credentials/key" - -# Hypothetical basic auth plugin - load password -easyhaproxy.plugin.basic_auth.k8s_secret.password: "auth-secret/pwd" -``` - -### Priority Order - -When multiple configuration methods are used, this is the priority (highest to lowest): - -1. **Explicit annotation** (e.g., `easyhaproxy.plugin.jwt_validator.pubkey: "value"`) -2. **k8s_secret annotation** (e.g., `easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret"`) - -Explicit annotations always take precedence over `k8s_secret` annotations. - -### Troubleshooting - -**Secret not found:** -``` -WARNING: Ingress production/api-ingress - Failed to process k8s_secret annotation -'easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey' with value 'jwt-secret': ... -``` -- Verify the secret exists: `kubectl get secret jwt-secret -n production` -- Check the secret is in the same namespace as the ingress - -**Key not found in secret:** -``` -WARNING: Ingress production/api-ingress - Secret 'jwt-secret' found but no matching -key (tried: pubkey, public-key, jwt.pub, tls.crt) -``` -- List secret keys: `kubectl get secret jwt-secret -n production -o jsonpath='{.data}'` -- Use explicit key format: `"jwt-secret/actual-key-name"` - -**Check EasyHAProxy logs:** -```bash -kubectl logs -n easyhaproxy -l app=easyhaproxy --tail=100 -``` - -Look for: -- `INFO: Loaded 'pubkey' from secret 'jwt-secret'` (success) -- `WARNING: Secret 'xyz' found but no matching key` (key not found) - -### Security Considerations - -- Secrets are read from the **same namespace** as the ingress (no cross-namespace access) -- EasyHAProxy needs RBAC permissions to read secrets (included in default deployment) -- Secrets are encrypted at rest in etcd -- Secret values are base64-encoded by Kubernetes automatically -- Use Kubernetes RBAC to control which service accounts can read which secrets - -## Certbot / ACME / Letsencrypt - -It is necessary to add the annotation `easyhaproxy.certbot` to the ingress configuration: - -```yaml -kind: Ingress -metadata: - annotations: - easyhaproxy.certbot: 'true' - name: example-ingress - namespace: example -spec: - ingressClassName: easyhaproxy - .... -``` - -More info [here](acme.md). - -Make sure your cluster is accessible both through ports 80 and 443. - -## Custom SSL Certificates - -Create a secret with your certificate and key and associate them with your ingress. - -```yaml ---- -apiVersion: v1 -kind: Secret -metadata: - name: host2-tls - namespace: default -data: - tls.crt: base64 of your certificate - tls.key: base64 of your certificate private key -type: kubernetes.io/tls - ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: tls-example - namespace: default -spec: - ingressClassName: easyhaproxy - tls: - - hosts: - - host2.local - secretName: host2-tls - rules: - ... -``` - ----- -[Open source ByJG](http://opensource.byjg.com) diff --git a/docs/plugin-development.md b/docs/plugin-development.md deleted file mode 100644 index 629979d..0000000 --- a/docs/plugin-development.md +++ /dev/null @@ -1,2094 +0,0 @@ ---- -sidebar_position: 16 ---- - -# 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. INITIALIZE PHASE - ├─ Calls plugin.initialize() for each plugin - ├─ Plugins request file system resources (directories, files) - ├─ PluginManager processes resource requests - └─ Creates directories and files as needed - -4. EXECUTION PHASE (per discovery cycle) - ├─ GLOBAL PLUGINS - │ └─ Executes all global plugins once - │ - └─ DOMAIN PLUGINS - └─ For each discovered domain: - └─ Executes all domain plugins - -5. RESULT PROCESSING - ├─ Collects PluginResult from each plugin - ├─ Injects haproxy_config into backend sections - ├─ Injects global_configs into global section - ├─ Injects defaults_configs into defaults section - ├─ 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/easyhaproxy/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/easyhaproxy/plugins/` (or builtin location for core plugins): - -```python -# /etc/easyhaproxy/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 logger_easyhaproxy - - -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/easyhaproxy/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 - - def initialize(self) -> InitializationResult: - """ - Initialize plugin resources (new in v2.0) - - Optional method to request file system resources. - Default implementation returns empty result (no-op). - - Returns: - InitializationResult with resource requests - """ - return InitializationResult() - - @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 -- `initialize()` - **[New in v2.0]** Request file system resources (optional) -- `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") -``` - -### ResourceRequest - -**[New in v2.0]** Request for file system resources during plugin initialization. - -```python -@dataclass -class ResourceRequest: - """Request for file system resources""" - resource_type: str # "directory" or "file" - path: str - content: str | None = None - overwrite: bool = False -``` - -**Fields:** - -- `resource_type` - Type of resource: `"directory"` or `"file"` -- `path` - Absolute path to create -- `content` - File content (only for `resource_type="file"`) -- `overwrite` - Whether to overwrite existing files (default: False) - -**Example:** - -```python -ResourceRequest( - resource_type="directory", - path="/etc/easyhaproxy/plugin_data" -) - -ResourceRequest( - resource_type="file", - path="/etc/easyhaproxy/plugin_config.txt", - content="config data", - overwrite=True -) -``` - -### InitializationResult - -**[New in v2.0]** Plugin initialization result with resource requests. - -```python -@dataclass -class InitializationResult: - """Plugin initialization result with resource requests""" - resources: list[ResourceRequest] = field(default_factory=list) - metadata: dict[str, Any] = field(default_factory=dict) -``` - -**Fields:** - -- `resources` - List of ResourceRequest objects -- `metadata` - Optional metadata about initialization - -**Example:** - -```python -def initialize(self) -> InitializationResult: - return InitializationResult( - resources=[ - ResourceRequest(resource_type="directory", path="/etc/easyhaproxy/jwt_keys") - ] - ) -``` - -### 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 - global_configs: list[str] = field(default_factory=list) # [New] Global-level configs - defaults_configs: list[str] = field(default_factory=list) # [New] Defaults-level configs -``` - -**Fields:** - -- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend) -- `modified_easymapping` - Modified easymapping structure (optional, advanced use) -- `metadata` - Dictionary with debugging/logging information -- `global_configs` - **[New in v2.0]** List of global-level HAProxy configs (e.g., fcgi-app definitions) -- `defaults_configs` - **[New in v2.0]** List of defaults-level HAProxy configs (e.g., log-format) - -**Examples:** - -```python -# Simple config injection (backend-level) -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 - } -) - -# With global-level config (new in v2.0) -return PluginResult( - haproxy_config="use-fcgi-app fcgi_example_com", - global_configs=[ - "fcgi-app fcgi_example_com\n docroot /etc/easyhaproxy/www" - ] -) - -# With defaults-level config (new in v2.0) -return PluginResult( - haproxy_config="acl from_cloudflare src -f /etc/easyhaproxy/cloudflare_ips.lst", - defaults_configs=[ - 'log-format "%{+Q}[var(txn.real_ip)]:-/%ci:%cp [%tr] %ft %b/%s"' - ] -) - -# 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 | None = None, abort_on_error: bool = False): - """ - Initialize the plugin manager - - Args: - plugins_dir: Directory containing plugin files (defaults to - EASYHAPROXY_PLUGINS_DIR env var or /etc/easyhaproxy/plugins) - 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 initialize_plugins(self) -> None: - """[New in v2.0] Initialize all plugins and process resource requests""" - - 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""" -``` - -**Environment Variables:** - -- `EASYHAPROXY_PLUGINS_DIR` - Override plugin directory (default: `/etc/easyhaproxy/plugins`) - -**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: /etc/easyhaproxy/www) - - 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: /etc/easyhaproxy/www - 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 logger_easyhaproxy - - -class FastcgiPlugin(PluginInterface): - """Plugin to configure FastCGI parameters for PHP-FPM""" - - def __init__(self): - self.enabled = True - self.document_root = "/etc/easyhaproxy/www" - 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 - metadata = { - "domain": context.domain, - "fcgi_app_name": fcgi_app_name, - "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, - global_configs=[fcgi_app_definition] # For top-level injection (new in v2.0) - ) -``` - -### 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/easyhaproxy/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/easyhaproxy/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 ( - InitializationResult, - PluginInterface, - PluginType, - PluginContext, - PluginResult, - ResourceRequest -) -from functions import Functions, logger_easyhaproxy - - -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 - # Make JWT_KEYS_DIR configurable via environment variable - self.jwt_keys_dir = os.getenv("EASYHAPROXY_JWT_KEYS_DIR", "/etc/easyhaproxy/jwt_keys") - - @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 initialize(self) -> InitializationResult: - """ - Initialize plugin resources - create JWT keys directory (new in v2.0) - - Returns: - InitializationResult with directory creation request - """ - return InitializationResult( - resources=[ - ResourceRequest(resource_type="directory", path=self.jwt_keys_dir) - ] - ) - - 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"{self.jwt_keys_dir}/{domain_safe}_pubkey.pem" - - # Write the public key file (defensive - normally created by initialize()) - try: - os.makedirs(self.jwt_keys_dir, exist_ok=True) - Functions.save(pubkey_file, self.pubkey) - logger_easyhaproxy.debug(f"Wrote JWT public key to {pubkey_file} for domain {context.domain}") - except (PermissionError, OSError) as e: - logger_easyhaproxy.debug(f"Could not write JWT public key file: {e}") - else: - logger_easyhaproxy.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 logger_easyhaproxy - - -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: - logger_easyhaproxy.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}") - logger_easyhaproxy.debug(f"Cleanup plugin: Removed {filepath}") - except Exception as e: - logger_easyhaproxy.warning(f"Failed to remove temp file {filepath}: {e}") - except Exception as e: - logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") - - # Log cleanup summary - if cleanup_actions: - logger_easyhaproxy.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 - } - ) -``` - ---- - -## Environment Variables - -**New in v2.0:** Plugins can use environment variables for configuration. - -### Core Environment Variables - -- `EASYHAPROXY_PLUGINS_DIR` - Override plugin directory (default: `/etc/easyhaproxy/plugins`) -- `EASYHAPROXY_PLUGINS_ENABLED` - Comma-separated list of enabled plugins -- `EASYHAPROXY_PLUGINS_ABORT_ON_ERROR` - Abort on plugin errors (default: `false`) - -### Plugin-Specific Environment Variables - -- `EASYHAPROXY_PLUGIN__` - Configure plugin settings - -**Example:** -```bash -EASYHAPROXY_PLUGINS_ENABLED=jwt_validator,cloudflare -EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ALGORITHM=RS256 -EASYHAPROXY_PLUGIN_JWT_VALIDATOR_ISSUER=https://auth.example.com/ -EASYHAPROXY_JWT_KEYS_DIR=/custom/path/jwt_keys # Plugin-defined env var -``` - -### Plugin Resource Directories - -**New in v2.0:** Plugins can make their resource directories configurable via environment variables. - -**Example:** -```python -class MyPlugin(PluginInterface): - def __init__(self): - # Make resource directory configurable - self.data_dir = os.getenv("EASYHAPROXY_MY_PLUGIN_DATA_DIR", "/etc/easyhaproxy/my_plugin_data") - - def initialize(self) -> InitializationResult: - return InitializationResult( - resources=[ - ResourceRequest(resource_type="directory", path=self.data_dir) - ] - ) -``` - ---- - -## Best Practices - -### 1. Use Plugin Initialization for Resource Setup - -**[New in v2.0]** Use the `initialize()` method to request file system resources. - -**Do:** -```python -def initialize(self) -> InitializationResult: - return InitializationResult( - resources=[ - ResourceRequest(resource_type="directory", path=self.data_dir) - ] - ) - -def process(self, context: PluginContext) -> PluginResult: - # Directory already exists, just use it - filepath = os.path.join(self.data_dir, "data.txt") - with open(filepath, 'w') as f: - f.write("data") -``` - -**Don't:** -```python -def process(self, context: PluginContext) -> PluginResult: - # Creating directories in process() is inefficient - os.makedirs(self.data_dir, exist_ok=True) # Called on every execution! - filepath = os.path.join(self.data_dir, "data.txt") -``` - -### 2. Use Typed Result Fields for Config Injection - -**[New in v2.0]** Use `global_configs` and `defaults_configs` fields instead of metadata. - -**Do:** -```python -return PluginResult( - haproxy_config="use-fcgi-app fcgi_example", - global_configs=["fcgi-app fcgi_example\n docroot /var/www"], - defaults_configs=['log-format "..."'] -) -``` - -**Don't:** -```python -# Deprecated: Don't put config in metadata -return PluginResult( - haproxy_config="use-fcgi-app fcgi_example", - metadata={ - "fcgi_app_definition": "fcgi-app fcgi_example\n docroot /var/www" # Wrong! - } -) -``` - -### 3. 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: - logger_easyhaproxy.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! -``` - -### 4. 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: - logger_easyhaproxy.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") -``` - -### 5. 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 - } -) -``` - -### 6. 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"] -``` - -### 7. 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 = [] -``` - -### 8. 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? -``` - -### 9. 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 -""" -``` - -### 10. 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... -``` - -### 11. Use Logger Appropriately - -Use appropriate log levels for different messages. - -```python -from functions import logger_easyhaproxy - -# For debugging -logger_easyhaproxy.debug(f"Processing domain: {context.domain}") - -# For informational messages -logger_easyhaproxy.info(f"Loaded plugin configuration: {self.name}") - -# For warnings (non-fatal issues) -logger_easyhaproxy.warning(f"Invalid configuration value, using default") - -# For errors (fatal issues) -logger_easyhaproxy.error(f"Failed to load required file: {filepath}") -``` - -### 12. 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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/plugins/ - # Ensure my_plugin.py exists - ``` - -2. **Invalid Python syntax** - ```bash - python3 -m py_compile /etc/easyhaproxy/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/easyhaproxy/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: - logger_easyhaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}") - logger_easyhaproxy.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: - logger_easyhaproxy.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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/easyhaproxy/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/reference/_category_.json b/docs/reference/_category_.json new file mode 100644 index 0000000..9b5c74a --- /dev/null +++ b/docs/reference/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Reference", + "position": 4 +} diff --git a/docs/pip.md b/docs/reference/cli.md similarity index 67% rename from docs/pip.md rename to docs/reference/cli.md index f0b9b64..23671ff 100644 --- a/docs/pip.md +++ b/docs/reference/cli.md @@ -1,84 +1,19 @@ --- -sidebar_position: 5 +sidebar_position: 1 +sidebar_label: "CLI Reference" --- -# Install via pip / uv +# CLI Reference -EasyHAProxy can run directly on any Linux or macOS host without Docker, using the `easyhaproxy` Python package. - -## Prerequisites - -HAProxy must be installed and available in your system `PATH` before running `easy-haproxy`. EasyHAProxy will refuse to start with a clear error message if HAProxy is not found. - -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - - - - -```bash -sudo apt install haproxy -``` - - - - -```bash -sudo dnf install haproxy -``` - - - - -```bash -brew install haproxy -``` - - - - -## Installation - -### Recommended: `uv tool` (system-wide, isolated) - -[`uv`](https://docs.astral.sh/uv/) installs `easyhaproxy` into its own isolated environment and exposes the `easy-haproxy` binary in `~/.local/bin/`, similar to `pipx`. - -```bash -# Install uv (if not already installed) -curl -LsSf https://astral.sh/uv/install.sh | sh - -# Install easyhaproxy as a tool -uv tool install easyhaproxy - -# Make sure ~/.local/bin is in PATH (one-time setup) -uv tool update-shell -``` - -After installation: - -```bash -easy-haproxy --help -``` - -### Alternative: `pip` - -```bash -pip install easyhaproxy -``` - -:::note Virtual environments -When installing inside a virtual environment, `easy-haproxy` is only available while the environment is activated. For system-wide use, prefer `uv tool install` or install with `pip` at the system/user level. -::: - -## CLI Reference - -Every configuration option can be set via a CLI flag **or** an environment variable. CLI flags take precedence over environment variables. +The `easy-haproxy` command is the native binary installed via `pip` or `uv`. Every option can be set as a **CLI flag** or an **environment variable**. CLI flags take precedence over environment variables. ``` easy-haproxy [OPTIONS] ``` -### Core +For installation instructions, see [Native install](../getting-started/native.md). + +## Core | Flag | Environment Variable | Default | Description | |--------------------------|----------------------------|------------------------------------------------------|-----------------------------------------------------------| @@ -89,7 +24,7 @@ easy-haproxy [OPTIONS] | `--refresh-conf SECONDS` | `EASYHAPROXY_REFRESH_CONF` | `10` | Polling interval for configuration changes | | `--customer-errors BOOL` | `HAPROXY_CUSTOMERRORS` | `false` | Enable custom HAProxy HTML error pages | -### Logging +## Logging | Flag | Environment Variable | Default | Description | |-----------------------------|-------------------------|----------|---------------------------| @@ -99,7 +34,7 @@ easy-haproxy [OPTIONS] Valid levels: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL` -### Stats Dashboard +## Stats Dashboard | Flag | Environment Variable | Default | Description | |--------------------------------------|-----------------------------|--------------|---------------------------------------------| @@ -112,7 +47,7 @@ Valid levels: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL` The stats dashboard is only enabled when `--haproxy-password` (or `HAPROXY_PASSWORD`) is set. ::: -### ACME / Certbot (SSL certificates) +## ACME / Certbot (SSL certificates) | Flag | Environment Variable | Default | Description | |---------------------------------------|--------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -125,18 +60,18 @@ The stats dashboard is only enabled when `--haproxy-password` (or `HAPROXY_PASSW | `--certbot-preferred-challenges TYPE` | `EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES` | `http` | ACME challenge type | | `--certbot-manual-auth-hook SCRIPT` | `EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK` | *(none)* | Path to a manual auth hook script for certbot | -See the full [ACME documentation](acme.md) for details. +See the full [ACME documentation](../guides/acme.md) for details. -### Plugins +## Plugins | Flag | Environment Variable | Default | Description | |---------------------------------|--------------------------------------|----------|-------------------------------------------| | `--plugins-enabled LIST` | `EASYHAPROXY_PLUGINS_ENABLED` | *(none)* | Comma-separated list of plugins to enable | | `--plugins-abort-on-error BOOL` | `EASYHAPROXY_PLUGINS_ABORT_ON_ERROR` | `false` | Abort startup if a plugin fails to load | -See the [plugins documentation](plugins.md) for available plugins. +See the [plugins guide](../guides/plugins.md) for available plugins. -### Kubernetes +## Kubernetes | Flag | Environment Variable | Default | Description | |--------------------------------------------|--------------------------------------|----------|----------------------------------------------| @@ -144,68 +79,3 @@ See the [plugins documentation](plugins.md) for available plugins. | `--deployment-mode MODE` | `EASYHAPROXY_DEPLOYMENT_MODE` | `auto` | Deployment mode: `auto`, `single`, `cluster` | | `--external-hostname HOSTNAME` | `EASYHAPROXY_EXTERNAL_HOSTNAME` | *(none)* | External hostname reported in Ingress status | | `--ingress-status-update-interval SECONDS` | `EASYHAPROXY_STATUS_UPDATE_INTERVAL` | `30` | Interval to update Ingress status | - -## Quick-start examples - -### Static mode (bare-metal / VM) - -```bash -mkdir -p ~/easyhaproxy/static - -cat > ~/easyhaproxy/static/config.yml <