1
0
Fork 0

Merge pull request #67 from byjg/ingressclassname

Add ingress status update and support for ingress class changes
This commit is contained in:
Joao Gilberto Magalhaes 2026-02-18 17:51:06 -05:00 committed by GitHub
commit deade0a928
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
205 changed files with 14071 additions and 2907 deletions

View file

@ -36,19 +36,134 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install requirements - name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: | run: |
cd src/ export PATH="$HOME/.local/bin:$PATH"
pip install -r requirements.txt uv sync --group dev
- name: Run tests - name: Run tests
run: | run: |
cd src/ export PATH="$HOME/.local/bin:$PATH"
pytest -s tests/ -vv uv run pytest -s tests/ -vv
Tests-E2E-Docker:
runs-on: ubuntu-latest
timeout-minutes: 20
needs: [Test]
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
export PATH="$HOME/.local/bin:$PATH"
uv sync --group dev
- name: Run Docker Compose E2E tests
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_docker_compose.py -sv --tb=short
Tests-E2E-Kubernetes:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [Test]
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
export PATH="$HOME/.local/bin:$PATH"
uv sync --group dev
- name: Run Kubernetes E2E tests
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_kubernetes.py -sv --tb=short
Tests-E2E-Additional:
runs-on: ubuntu-latest
needs: [Test]
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
export PATH="$HOME/.local/bin:$PATH"
uv sync --group dev
- name: Clean Docker build cache
run: docker builder prune -af
- name: Run Static E2E tests
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_static.py -sv --tb=short
- name: Run Proxy Headers E2E tests
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_proxy_headers.py -sv --tb=short
Tests-E2E-Swarm:
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [Test]
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
export PATH="$HOME/.local/bin:$PATH"
uv sync --group dev
- name: Clean Docker build cache
run: docker builder prune -af
- name: Build Docker image
env:
DOCKER_BUILDKIT: "0"
run: docker build -t byjg/easy-haproxy:local -f deploy/docker/Dockerfile .
- name: Run Swarm E2E tests
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest tests_e2e/test_swarm.py -sv --tb=short
Build: Build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: Test needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional, Tests-E2E-Swarm]
permissions: permissions:
contents: read contents: read
packages: write packages: write
@ -64,7 +179,7 @@ jobs:
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log into registry - name: Log into registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' if: github.event_name == 'push' || github.event.inputs.push == 'true'
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ secrets.DOCKER_REGISTRY }} registry: ${{ secrets.DOCKER_REGISTRY }}
@ -127,11 +242,11 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: build/Dockerfile file: deploy/docker/Dockerfile
build-args: | build-args: |
RELEASE_VERSION_ARG="${{ steps.tags.outputs.result }}" RELEASE_VERSION_ARG="${{ steps.tags.outputs.result }}"
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }} push: ${{ github.event_name == 'push' || github.event.inputs.push == 'true' }}
tags: ${{ steps.normalized.outputs.result }} tags: ${{ steps.normalized.outputs.result }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
@ -140,12 +255,35 @@ jobs:
# - name: Docker Hub Description # - name: Docker Hub Description
# if: github.event_name != 'pull_request' || github.event.inputs.push == 'true' # if: github.event_name == 'push' || github.event.inputs.push == 'true'
# run: | # run: |
# wget -q https://github.com/christian-korneck/docker-pushrm/releases/download/v1.8.0/docker-pushrm_linux_amd64 -O $HOME/.docker/cli-plugins/docker-pushrm # wget -q https://github.com/christian-korneck/docker-pushrm/releases/download/v1.8.0/docker-pushrm_linux_amd64 -O $HOME/.docker/cli-plugins/docker-pushrm
# chmod +x $HOME/.docker/cli-plugins/docker-pushrm # chmod +x $HOME/.docker/cli-plugins/docker-pushrm
# docker pushrm ${{ env.IMAGE_NAME }} # docker pushrm ${{ env.IMAGE_NAME }}
Publish-PyPI:
runs-on: ubuntu-latest
needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional, Tests-E2E-Swarm]
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
id-token: write # Required for OIDC trusted publisher
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Build package
run: |
export PATH="$HOME/.local/bin:$PATH"
uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
Helm: Helm:
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
needs: Build needs: Build
@ -191,4 +329,5 @@ jobs:
with: with:
folder: devops folder: devops
project: ${{ github.event.repository.name }} project: ${{ github.event.repository.name }}
secrets: inherit secrets:
DOC_TOKEN: ${{ secrets.DOC_TOKEN }}

27
.gitignore vendored
View file

@ -6,12 +6,21 @@ __pycache__
.pytest_cache .pytest_cache
*.pyc *.pyc
.env .env
/examples/static/conf/config.yml
/examples/docker/certs/haproxy/.place_holder_cert.pem # uv
/examples/static/host1.local.pem .venv/
/examples/swarm/certs/host1.local.pem
/examples/docker/host2.local.pem # Build artifacts
/examples/swarm/certs/host2.local.pem dist/
/examples/docker/jwt_private.pem *.egg-info/
/examples/docker/jwt_pubkey.pem
/examples/docker/cloudflare_ips.lst /tests_e2e/static/conf/config.yml
/tests_e2e/docker/certs/haproxy/.place_holder_cert.pem
/tests_e2e/static/host1.local.pem
/tests_e2e/swarm/certs/host1.local.pem
/tests_e2e/docker/host2.local.pem
/tests_e2e/swarm/certs/host2.local.pem
/tests_e2e/docker/jwt_private.pem
/tests_e2e/docker/jwt_pubkey.pem
/tests_e2e/docker/cloudflare_ips.lst
/tests_e2e/docker/pebble-ca.pem

View file

@ -1,9 +0,0 @@
# This configuration file was automatically generated by Gitpod.
# Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file)
# and commit this file to your remote git repository to share the goodness with others.
tasks:
- command: |
virtualenv -p /usr/bin/python3 venv
source venv/bin/activate
pip install -r src/requirements.txt

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

View file

@ -5,16 +5,17 @@
<option name="INTERPRETER_OPTIONS" value="" /> <option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" /> <option name="PARENT_ENVS" value="true" />
<option name="SDK_HOME" value="" /> <option name="SDK_HOME" value="" />
<option name="SDK_NAME" value="Python 3.12 (docker-easy-haproxy)" /> <option name="SDK_NAME" value="uv (docker-easy-haproxy)" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
<option name="IS_MODULE_SDK" value="false" /> <option name="IS_MODULE_SDK" value="false" />
<option name="ADD_CONTENT_ROOTS" value="true" /> <option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" /> <option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" /> <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="RUN_TOOL" value="true" />
<option name="_new_keywords" value="&quot;&quot;" /> <option name="_new_keywords" value="&quot;&quot;" />
<option name="_new_parameters" value="&quot;&quot;" /> <option name="_new_parameters" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" /> <option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/src/tests&quot;" /> <option name="_new_target" value="&quot;$PROJECT_DIR$/tests&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" /> <option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" /> <method v="2" />
</configuration> </configuration>

View file

@ -2,8 +2,24 @@ VERSION := $(shell git rev-parse --short HEAD)
.PHONY: build .PHONY: build
build: build:
docker build -t byjg/easy-haproxy --build-arg RELEASE_VERSION_ARG="$(VERSION)" -t byjg/easy-haproxy:local -f build/Dockerfile . docker build -t byjg/easy-haproxy --build-arg RELEASE_VERSION_ARG="$(VERSION)" -t byjg/easy-haproxy:local -f deploy/docker/Dockerfile .
.PHONY: test .PHONY: test
test: test:
cd src/ && pytest tests/ -vv uv run pytest tests/ -vv
.PHONY: sync
sync:
uv sync --dev
.PHONY: lint
lint:
uv run ruff check src/ tests/
.PHONY: fix
fix:
uv run ruff check --fix src/ tests/
.PHONY: format
format:
uv run ruff format src/ tests/

View file

@ -100,6 +100,47 @@ Detailed configuration guides for advanced setups:
- [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.) - [Other Configurations](docs/other.md) - Additional configurations (ports, custom errors, etc.)
- [Limitations](docs/limitations.md) - Important limitations and considerations - [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]"
```
## See EasyHAProxy in action ## See EasyHAProxy in action
Click on the image to see the videos (use HD for better visualization) Click on the image to see the videos (use HD for better visualization)
@ -111,7 +152,7 @@ Click on the image to see the videos (use HD for better visualization)
[![Static Configuration](docs/video-static.png)](https://youtu.be/B_bYZnRTGJM) [![Static Configuration](docs/video-static.png)](https://youtu.be/B_bYZnRTGJM)
[![TCP Mode](docs/video-tcp-mysql.png)](https://youtu.be/JHqcq9crbDI) [![TCP Mode](docs/video-tcp-mysql.png)](https://youtu.be/JHqcq9crbDI)
[Here is the code](https://gist.github.com/byjg/e125e478a0562190176d69ea795fd3d4) applied in the examples above. [Here is the code](https://gist.github.com/byjg/e125e478a0562190176d69ea795fd3d4) applied in the test examples above.
---- ----

View file

@ -54,7 +54,7 @@ EasyHAProxy follows [Semantic Versioning](https://semver.org/):
- **MINOR**: New features, plugin additions, backward-compatible changes - **MINOR**: New features, plugin additions, backward-compatible changes
- **PATCH**: Bug fixes, documentation updates, minor improvements - **PATCH**: Bug fixes, documentation updates, minor improvements
**Current Version:** `5.0.0` (as of Chart.yaml) **Current Version:** `6.0.0` (as of Chart.yaml)
## Automated Release (Recommended) ## Automated Release (Recommended)
@ -80,7 +80,7 @@ The automated release process is triggered by pushing a semantic version tag.
```bash ```bash
make build make build
# Or manually: # Or manually:
docker build -t byjg/easy-haproxy:local -f build/Dockerfile . docker build -t byjg/easy-haproxy:local -f deploy/docker/Dockerfile .
``` ```
### Step 2: Bump Versions (script + PR) ### Step 2: Bump Versions (script + PR)
@ -329,6 +329,7 @@ helm show chart byjg/easyhaproxy
| Version | Release Date | Type | Highlights | | Version | Release Date | Type | Highlights |
|---------|--------------|-------|---------------------------------------------------------------------------------------------------------------------------------------| |---------|--------------|-------|---------------------------------------------------------------------------------------------------------------------------------------|
| 6.0.0 | 2026-XX-XX | Major | Python module refactoring (one-class-per-file), IngressClassName support (spec.ingressClassName + deprecated annotation fallback), modernized build system (uv/pyproject.toml), improved version management and release tooling, GitHub Actions workflow_dispatch push control |
| 5.0.0 | 2025-12-04 | Major | Plugin framework (builtin plugins: JWT, FastCGI, Cloudflare, IP whitelist, deny pages, cleanup), docs restructure, examples refreshed | | 5.0.0 | 2025-12-04 | Major | Plugin framework (builtin plugins: JWT, FastCGI, Cloudflare, IP whitelist, deny pages, cleanup), docs restructure, examples refreshed |
| 4.6.0 | 2024-11-27 | Minor | FastCGI plugin, JWT enhancements | | 4.6.0 | 2024-11-27 | Minor | FastCGI plugin, JWT enhancements |
| 4.5.0 | 2024-XX-XX | Minor | Previous release | | 4.5.0 | 2024-XX-XX | Minor | Previous release |

View file

@ -17,7 +17,7 @@ podman run --rm --events-backend=file --cgroup-manager=cgroupfs --privileged doc
for VERSION in $VERSIONS for VERSION in $VERSIONS
do do
DOCKERFILE=build/Dockerfile DOCKERFILE=deploy/docker/Dockerfile
buildah manifest create byjg/easy-haproxy:$VERSION buildah manifest create byjg/easy-haproxy:$VERSION

View file

@ -1,27 +0,0 @@
FROM alpine:3.22
ARG RELEASE_VERSION_ARG
ENV RELEASE_VERSION=$RELEASE_VERSION_ARG
ENV TZ="Etc/UTC"
RUN apk add --no-cache haproxy bash python3 py3-pip py-yaml certbot openssl \
&& apk add --no-cache --virtual .build-deps build-base python3-dev musl-dev linux-headers \
&& pip3 install --upgrade pip --break-system-packages
RUN openssl dhparam -out /etc/haproxy/dhparam 2048 \
&& openssl dhparam -out /etc/haproxy/dhparam-1024 1024
WORKDIR /scripts
COPY build/assets /
COPY src/ /scripts/
RUN pip install -r requirements.txt --break-system-packages
RUN apk del .build-deps
RUN pytest -s -vv tests/
CMD ["/usr/bin/python", "-u", "/scripts/main.py" ]

View file

@ -1,51 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDyTCCArGgAwIBAgIUVpIokbXupa29drpQBmRlKTViM+UwDQYJKoZIhvcNAQEL
BQAwdDELMAkGA1UEBhMCQVUxFTATBgNVBAgMDFBsYWNlIEhvbGRlcjEVMBMGA1UE
BwwMUGxhY2UgSG9sZGVyMSEwHwYDVQQKDBhQbGFjZSBIb2xkZXIgQ2VydGlmaWNh
dGUxFDASBgNVBAMMC2V4YW1wbGUub3JnMB4XDTIyMDgxNTA0NTEwMFoXDTMyMDgx
MjA0NTEwMFowdDELMAkGA1UEBhMCQVUxFTATBgNVBAgMDFBsYWNlIEhvbGRlcjEV
MBMGA1UEBwwMUGxhY2UgSG9sZGVyMSEwHwYDVQQKDBhQbGFjZSBIb2xkZXIgQ2Vy
dGlmaWNhdGUxFDASBgNVBAMMC2V4YW1wbGUub3JnMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAqufw4FdqYcJep7mHHcYGUN79GNBLpvAIdg+1NbKx+cB/
PtaDuozqVkkT8CmM0Mruay4vCbYkMytCeKgHj2+hLMy7oUQvx2pK/V0i0foPAC0m
gAvgmaWZbQTENHX4A0Rwvim0yixgeBVhz4hTMOIunilSXbKRkFBUidCnYQe1Nzy1
dbH/fh8++fzLCglDE2kydrE3Zq/54G2xFOxPt1DZRnQ3RBYaMIR/uPPjpVxRWl+p
w4ucklAIZcu2htlOpGl7/3baMtnhpTo9LrkWzSNS7CJQvj6BvDULbcN3+hNPOoVE
MM9MCaM9V+FS25kf+DfyaVUVDVIv0thpwa+f3tCXSwIDAQABo1MwUTAdBgNVHQ4E
FgQUV831A1qfpV+Sy/J+lN6LKLgopsQwHwYDVR0jBBgwFoAUV831A1qfpV+Sy/J+
lN6LKLgopsQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAhnf7
aW/jpr8JWC0l2lo0z1HpOaSNehhcfNyM6Y43lR3Lr1avibbIXkAOzCuSWanFNnex
dOq8PbG9bmO1ncM6qzXRBzk4pLVJmzzDZs/fPVghSZumY2bzzJFtdCQ5FiLaaE/c
cKtBPvoUjfvrBU9OwFSb9UaoQdxtateb/Kk6JfzWi6YZqxSXFNa0ZTWJaoRFQVj5
bZZe+wgpGRz46p+2YMwsNolXxa+7yY9x6kOMqZP6++5LZGXm5iWxjWbN4WtqmNnN
cK35fAdLlg4d3wn5tVuTkpKH0FcaRlgpBSjVKejgnFTCxKcOwOCqOfu+nuSSWpa7
aRqdAbMTeVFQhyLXhQ==
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCq5/DgV2phwl6n
uYcdxgZQ3v0Y0Eum8Ah2D7U1srH5wH8+1oO6jOpWSRPwKYzQyu5rLi8JtiQzK0J4
qAePb6EszLuhRC/Hakr9XSLR+g8ALSaAC+CZpZltBMQ0dfgDRHC+KbTKLGB4FWHP
iFMw4i6eKVJdspGQUFSJ0KdhB7U3PLV1sf9+Hz75/MsKCUMTaTJ2sTdmr/ngbbEU
7E+3UNlGdDdEFhowhH+48+OlXFFaX6nDi5ySUAhly7aG2U6kaXv/dtoy2eGlOj0u
uRbNI1LsIlC+PoG8NQttw3f6E086hUQwz0wJoz1X4VLbmR/4N/JpVRUNUi/S2GnB
r5/e0JdLAgMBAAECggEAI0nY9rmWAbF8ke1A9OjajQA+Ck2YEVQmqxn7NKc9EHCq
1XK9qFtIV6CnOUObC9GbAQ58L+kn+FjKVNd9GCTYhsOPSnEl3GsaKM5+ThTv2/12
oaHSMmd7EoOVb6+cEjCjhuBdsBERqjngBFYFt2Y8cfPeSfKBE+dCTWKD7QkGZe0Q
tEII2NeE1AoQwG34TANxyn+HbZG809i+k21Pm0tPNyAFwhPvotpjSftMNgco8jcx
39DCt7rG1etpc4VhIUUl8GqsNAHYg8/SfscE18PYeX7RJpKGS128E1mKCqWqn+Ie
VCiMmWoQ4ZBlUFp3tS4+FWcQtDr8Z82QTZQn3ODwIQKBgQDV1NR+SxjBBnzpHDZC
cTxCK0PU37VmPjyr6BAXlftCPLGLgKEVIDRuna6EoiZ0/K0RMDZRoaJgDQPoAXDV
UwJOWYtlJBzUxwQbBJc3S7C3kEC+qeRT9lRwIrn7P6gT1DMcSRsV4ULwGRWAMa2Q
6apbK/K+56Ds7LiStu3OcJSxDwKBgQDMnAvLm0uMo9ZceKG515ruqzQj2YPz2+Zc
f38pD1hESDRj1bzgT09FAsejPnlN7KPp8TFgRUB9Rqb6DZCkx49zdtcDkuZIKfQH
Ga7ITBXnTwe8M+nwq2Q2LJYPdB/p8mBqh4ujA2XIS7ZHCKqOFGsseP4H1uxZeORI
pIPQp4C+BQKBgQDKHw9tAZc4feV8g4pWa6rF8ReBFKTnLFU1OXpckQybo7s/Xirl
STfGh437GTq4wk7lPGlb6CkQGb1jhFkfjANWBBZbWDNYfXZIA6LcRdOY7+YDU5vc
Ma/G/0xFTfqWI7LcPc44dGFNiqhkMJEbtYOuAnDGOzRGP8yIAhnvVUN3yQKBgQC1
eaYglYGBoQMMi1Xt7iQVkbWyIkedr6l22wJe2aRRE7Wb4sQeM1m8fMWyrUOL8NpF
MU6481NKibKp0AQ9kl5Sa9Iy8kTbNpKhBY93SbyXpwnWTDku4+UDA7KozDdOGVKY
ydX45JeO+lAWWsJjOAsCq+Gr9F020jmvkHL1SsuuPQKBgQDHHCSOlUycLnzxEAks
uiu5MseFzkmdWN1ShrjPkcJ4HLmSD5zRgCySpBB92WPU+yBANlY7WN6fJHVJk/d7
sNAg78GuAxcWrNAQwu6DRjhnb8zTVICJX8HmDLmsoOHwLsShdckWMks5XEk3NzoV
4ym6aG0tDX+rkBkP/VIjSXA+Cg==
-----END PRIVATE KEY-----

View file

@ -1,3 +0,0 @@
# Custom HAProxy
Put files .cfg to be included in the configuration

53
deploy/docker/Dockerfile Normal file
View file

@ -0,0 +1,53 @@
# ==============================================================================
# Stage 1: Build Python virtual environment and run tests
# ==============================================================================
FROM haproxy:3.3-alpine AS builder
USER root
RUN apk add --no-cache python3 bash curl build-base python3-dev musl-dev linux-headers \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
WORKDIR /scripts
COPY pyproject.toml uv.lock LICENSE README.md ./
COPY src/ ./src/
COPY tests/ ./tests/
RUN uv sync --frozen
RUN uv run pytest -s -vv tests/
RUN uv sync --no-dev
RUN rm -rf tests/
# ==============================================================================
# Stage 2: Lean runtime image
# ==============================================================================
FROM haproxy:3.3-alpine
ARG RELEASE_VERSION_ARG
ENV RELEASE_VERSION=$RELEASE_VERSION_ARG
ENV TZ="Etc/UTC"
USER root
RUN apk add --no-cache certbot openssl bash curl su-exec \
&& mkdir -p /etc/easyhaproxy/haproxy \
&& mkdir -p /etc/easyhaproxy/certs/certbot /etc/easyhaproxy/certs/haproxy \
&& openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
-keyout /tmp/placeholder.key \
-out /tmp/placeholder.crt \
-subj "/CN=placeholder" \
&& cat /tmp/placeholder.crt /tmp/placeholder.key > /etc/easyhaproxy/certs/certbot/placeholder.pem \
&& cat /tmp/placeholder.crt /tmp/placeholder.key > /etc/easyhaproxy/certs/haproxy/placeholder.pem \
&& rm /tmp/placeholder.key /tmp/placeholder.crt
COPY deploy/docker/assets /
COPY --from=builder /scripts /scripts
RUN chmod +x /entrypoint.sh \
&& chown -R haproxy:haproxy /etc/easyhaproxy /scripts
ENTRYPOINT ["/entrypoint.sh"]
CMD ["--base-path", "/etc/easyhaproxy"]

View file

@ -0,0 +1,13 @@
#!/bin/sh
set -e
## When docker.sock is mounted its GID comes from the host and is unknown at
## build time. Detect it at startup and add the haproxy user to that group so
## Docker/Swarm discovery works without running the whole process as root.
#if [ -S /var/run/docker.sock ]; then
# SOCK_GID=$(stat -c '%g' /var/run/docker.sock)
# getent group "$SOCK_GID" >/dev/null 2>&1 || addgroup -g "$SOCK_GID" dockersock
# addgroup haproxy "$(getent group "$SOCK_GID" | cut -d: -f1)" 2>/dev/null || true
#fi
exec /scripts/.venv/bin/easy-haproxy "$@"

View file

@ -0,0 +1,5 @@
# Custom HAProxy Configuration
Put `.cfg` files here to be included in the HAProxy configuration.
These files will be available at `/etc/easyhaproxy/haproxy/conf.d/` inside the container.

View file

@ -4,7 +4,7 @@ Connection: close
Content-Type: text/html Content-Type: text/html
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>Ins
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>400 Bad request</title> <title>400 Bad request</title>

View file

@ -1,11 +1,16 @@
services: services:
easyhaproxy: easyhaproxy:
image: byjg/easy-haproxy:5.0.0 image: byjg/easy-haproxy:6.0.0
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- certs_certbot:/certs/certbot - certs_certbot:/etc/easyhaproxy/certs/certbot
- certs_haproxy:/certs/haproxy - certs_haproxy:/etc/easyhaproxy/certs/haproxy
healthcheck:
test: ["CMD", "curl", "-f", "-u", "admin:password", "http://localhost:1936"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 3
environment: environment:
EASYHAPROXY_DISCOVER: docker EASYHAPROXY_DISCOVER: docker
EASYHAPROXY_LABEL_PREFIX: easyhaproxy EASYHAPROXY_LABEL_PREFIX: easyhaproxy
@ -15,21 +20,19 @@ services:
HAPROXY_USERNAME: admin HAPROXY_USERNAME: admin
HAPROXY_PASSWORD: password HAPROXY_PASSWORD: password
HAPROXY_STATS_PORT: 1936 HAPROXY_STATS_PORT: 1936
HAPROXY_STATS_CORS_ORIGIN: "http://localhost:8080"
ports: ports:
- "80:80/tcp" - "80:80/tcp"
- "443:443/tcp" - "443:443/tcp"
- "1936:1936/tcp" - "1936:1936/tcp"
networks:
- easyhaproxy
volumes: volumes:
certs_certbot: certs_certbot:
external: true # external: true
certs_haproxy: certs_haproxy:
external: true # external: true
networks: networks:
easyhaproxy: easyhaproxy:
external: true # external: true

View file

@ -1,29 +0,0 @@
#!/bin/bash
ASSETS_DIR="$(dirname "${BASH_SOURCE[0]}")"/../../build/assets/certs/haproxy
docker network create easyhaproxy
docker volume create certs_certbot
docker volume create certs_haproxy
docker run -d --rm --name easyhaproxy_install -v certs_haproxy:/certs alpine tail -f /dev/null
docker cp $ASSETS_DIR/place_holder_cert.pem easyhaproxy_install:/certs/place_holder_cert.pem
docker stop easyhaproxy_install
echo
echo
echo make sure to add to all of your containers:
echo
echo docker-compose
echo ==============
echo "networks:"
echo " default:"
echo " name: easyhaproxy"
echo " external: true"
echo
echo
echo docker run
echo ==============
echo docker run ... --network easyhaproxy ... your_image:tag
echo

View file

@ -0,0 +1,91 @@
# Kubernetes Static Manifests
⚠️ **IMPORTANT**: These files are **auto-generated** from Helm templates. Do not edit them directly!
## About These Files
This directory contains pre-rendered Kubernetes manifests for deploying EasyHAProxy without Helm. These are generated from the Helm chart at `../../helm/easyhaproxy/` and provide three deployment options:
| File | Type | Use Case |
|-----------------------------|------------------------|--------------------------------------------------------------|
| `easyhaproxy-daemonset.yml` | DaemonSet + hostPort | Direct host networking, best for bare-metal or simple setups |
| `easyhaproxy-nodeport.yml` | Deployment + NodePort | Exposes via NodePort (31080/31443/31936) |
| `easyhaproxy-clusterip.yml` | Deployment + ClusterIP | Internal cluster access only, use with external LoadBalancer |
## How to Use
Choose the manifest that fits your deployment scenario:
```bash
# Option 1: DaemonSet mode (hostPort)
kubectl apply -f easyhaproxy-daemonset.yml
# Option 2: NodePort mode
kubectl apply -f easyhaproxy-nodeport.yml
# Option 3: ClusterIP mode
kubectl apply -f easyhaproxy-clusterip.yml
```
For more details, see the [Kubernetes documentation](../../docs/kubernetes.md).
## Regenerating These Files
**When to regenerate:**
- After modifying Helm chart templates (`helm/easyhaproxy/templates/`)
- After updating default values (`helm/easyhaproxy/values.yaml`)
- After a new release to sync with latest Helm chart
**How to regenerate:**
```bash
# Navigate to helm directory
cd helm
# Generate DaemonSet manifest (hostPort mode)
helm template ingress ./easyhaproxy --namespace easyhaproxy \
--set service.create=false \
> ../deploy/kubernetes/easyhaproxy-daemonset.yml
# Generate NodePort manifest
helm template ingress ./easyhaproxy --namespace easyhaproxy \
--set service.create=true \
--set service.type=NodePort \
> ../deploy/kubernetes/easyhaproxy-nodeport.yml
# Generate ClusterIP manifest
helm template ingress ./easyhaproxy --namespace easyhaproxy \
--set service.create=true \
--set service.type=ClusterIP \
> ../deploy/kubernetes/easyhaproxy-clusterip.yml
```
**Verify regeneration:**
```bash
# Check IngressClass is present
grep "kind: IngressClass" ../deploy/kubernetes/easyhaproxy-*.yml
# Validate manifest syntax
kubectl apply --dry-run=client -f ../deploy/kubernetes/easyhaproxy-daemonset.yml
```
## What's Included
Each manifest contains:
- **ServiceAccount**: RBAC identity for EasyHAProxy
- **ClusterRole**: Permissions to read Ingress resources and Secrets
- **ClusterRoleBinding**: Binds the role to the service account
- **IngressClass**: Defines `easyhaproxy` as the ingress class
- **DaemonSet/Deployment**: The EasyHAProxy workload
- **Service** (NodePort/ClusterIP only): Network exposure
## Source of Truth
The Helm chart at `../../helm/easyhaproxy/` is the **source of truth**. All changes should be made there, then these static manifests regenerated.
**To modify these deployments:**
1. Edit Helm templates in `helm/easyhaproxy/templates/`
2. Update default values in `helm/easyhaproxy/values.yaml`
3. Regenerate static manifests using commands above
4. Commit both Helm changes and regenerated manifests

View file

@ -6,10 +6,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
--- ---
# Source: easyhaproxy/templates/clusterrole.yaml # Source: easyhaproxy/templates/clusterrole.yaml
@ -19,10 +19,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
rules: rules:
- apiGroups: - apiGroups:
@ -30,7 +30,7 @@ rules:
resources: resources:
# - configmaps # - configmaps
# - endpoints # - endpoints
# - nodes - nodes
- pods - pods
- services - services
- namespaces - namespaces
@ -41,23 +41,21 @@ rules:
- list - list
- watch - watch
- apiGroups: - apiGroups:
- "extensions"
- "networking.k8s.io" - "networking.k8s.io"
resources: resources:
- ingresses - ingresses
# - ingresses/status - ingresses/status
# - ingressclasses - ingressclasses
verbs: verbs:
- get - get
- list - list
- watch - watch
# - apiGroups: - apiGroups:
# - "extensions" - "networking.k8s.io"
# - "networking.k8s.io" resources:
# resources: - ingresses/status
# - ingresses/status verbs:
# verbs: - patch
# - update
- apiGroups: - apiGroups:
- "" - ""
resources: resources:
@ -85,10 +83,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
@ -107,10 +105,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
annotations: annotations:
{} {}
@ -139,12 +137,13 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
@ -155,15 +154,6 @@ spec:
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
spec: spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: easyhaproxy/node
operator: In
values:
- master
serviceAccountName: ingress-easyhaproxy serviceAccountName: ingress-easyhaproxy
securityContext: securityContext:
{} {}
@ -171,7 +161,7 @@ spec:
- name: easyhaproxy - name: easyhaproxy
securityContext: securityContext:
{} {}
image: "byjg/easy-haproxy:5.0.0" image: "byjg/easy-haproxy:6.0.0"
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- name: http - name: http
@ -184,9 +174,7 @@ spec:
containerPort: 1936 containerPort: 1936
resources: resources:
requests: {}
cpu: 100m
memory: 128Mi
env: env:
- name: EASYHAPROXY_DISCOVER - name: EASYHAPROXY_DISCOVER
value: kubernetes value: kubernetes
@ -206,3 +194,27 @@ spec:
value: DEBUG value: DEBUG
- name: CERTBOT_LOG_LEVEL - name: CERTBOT_LOG_LEVEL
value: DEBUG value: DEBUG
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: EASYHAPROXY_UPDATE_INGRESS_STATUS
value: "true"
- name: EASYHAPROXY_DEPLOYMENT_MODE
value: "auto"
- name: EASYHAPROXY_STATUS_UPDATE_INTERVAL
value: "30"
---
# Source: easyhaproxy/templates/ingressclass.yaml
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: easyhaproxy
labels:
helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm
spec:
controller: byjg.com/easyhaproxy

View file

@ -6,10 +6,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
--- ---
# Source: easyhaproxy/templates/clusterrole.yaml # Source: easyhaproxy/templates/clusterrole.yaml
@ -19,10 +19,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
rules: rules:
- apiGroups: - apiGroups:
@ -30,7 +30,7 @@ rules:
resources: resources:
# - configmaps # - configmaps
# - endpoints # - endpoints
# - nodes - nodes
- pods - pods
- services - services
- namespaces - namespaces
@ -41,23 +41,21 @@ rules:
- list - list
- watch - watch
- apiGroups: - apiGroups:
- "extensions"
- "networking.k8s.io" - "networking.k8s.io"
resources: resources:
- ingresses - ingresses
# - ingresses/status - ingresses/status
# - ingressclasses - ingressclasses
verbs: verbs:
- get - get
- list - list
- watch - watch
# - apiGroups: - apiGroups:
# - "extensions" - "networking.k8s.io"
# - "networking.k8s.io" resources:
# resources: - ingresses/status
# - ingresses/status verbs:
# verbs: - patch
# - update
- apiGroups: - apiGroups:
- "" - ""
resources: resources:
@ -85,10 +83,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
@ -106,10 +104,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
spec: spec:
selector: selector:
@ -138,7 +136,7 @@ spec:
- name: easyhaproxy - name: easyhaproxy
securityContext: securityContext:
{} {}
image: "byjg/easy-haproxy:5.0.0" image: "byjg/easy-haproxy:6.0.0"
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- name: http - name: http
@ -151,9 +149,7 @@ spec:
containerPort: 1936 containerPort: 1936
hostPort: 1936 hostPort: 1936
resources: resources:
requests: {}
cpu: 100m
memory: 128Mi
env: env:
- name: EASYHAPROXY_DISCOVER - name: EASYHAPROXY_DISCOVER
value: kubernetes value: kubernetes
@ -173,3 +169,27 @@ spec:
value: DEBUG value: DEBUG
- name: CERTBOT_LOG_LEVEL - name: CERTBOT_LOG_LEVEL
value: DEBUG value: DEBUG
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: EASYHAPROXY_UPDATE_INGRESS_STATUS
value: "true"
- name: EASYHAPROXY_DEPLOYMENT_MODE
value: "auto"
- name: EASYHAPROXY_STATUS_UPDATE_INTERVAL
value: "30"
---
# Source: easyhaproxy/templates/ingressclass.yaml
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: easyhaproxy
labels:
helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm
spec:
controller: byjg.com/easyhaproxy

View file

@ -6,10 +6,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
--- ---
# Source: easyhaproxy/templates/clusterrole.yaml # Source: easyhaproxy/templates/clusterrole.yaml
@ -19,10 +19,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
rules: rules:
- apiGroups: - apiGroups:
@ -30,7 +30,7 @@ rules:
resources: resources:
# - configmaps # - configmaps
# - endpoints # - endpoints
# - nodes - nodes
- pods - pods
- services - services
- namespaces - namespaces
@ -41,23 +41,21 @@ rules:
- list - list
- watch - watch
- apiGroups: - apiGroups:
- "extensions"
- "networking.k8s.io" - "networking.k8s.io"
resources: resources:
- ingresses - ingresses
# - ingresses/status - ingresses/status
# - ingressclasses - ingressclasses
verbs: verbs:
- get - get
- list - list
- watch - watch
# - apiGroups: - apiGroups:
# - "extensions" - "networking.k8s.io"
# - "networking.k8s.io" resources:
# resources: - ingresses/status
# - ingresses/status verbs:
# verbs: - patch
# - update
- apiGroups: - apiGroups:
- "" - ""
resources: resources:
@ -85,10 +83,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
roleRef: roleRef:
apiGroup: rbac.authorization.k8s.io apiGroup: rbac.authorization.k8s.io
@ -107,10 +105,10 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
annotations: annotations:
{} {}
@ -119,13 +117,13 @@ spec:
ports: ports:
- name: http - name: http
port: 80 port: 80
nodePort: 31080 nodePort: 80
- name: https - name: https
port: 443 port: 443
nodePort: 31443 nodePort: 443
- name: stats - name: stats
port: 1936 port: 1936
nodePort: 31936 nodePort: 1936
selector: selector:
@ -139,12 +137,13 @@ metadata:
name: ingress-easyhaproxy name: ingress-easyhaproxy
namespace: easyhaproxy namespace: easyhaproxy
labels: labels:
helm.sh/chart: easyhaproxy-1.0.1 helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "5.0.0" app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm app.kubernetes.io/managed-by: Helm
spec: spec:
replicas: 1
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
@ -155,15 +154,6 @@ spec:
app.kubernetes.io/name: easyhaproxy app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress app.kubernetes.io/instance: ingress
spec: spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: easyhaproxy/node
operator: In
values:
- master
serviceAccountName: ingress-easyhaproxy serviceAccountName: ingress-easyhaproxy
securityContext: securityContext:
{} {}
@ -171,7 +161,7 @@ spec:
- name: easyhaproxy - name: easyhaproxy
securityContext: securityContext:
{} {}
image: "byjg/easy-haproxy:5.0.0" image: "byjg/easy-haproxy:6.0.0"
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- name: http - name: http
@ -184,9 +174,7 @@ spec:
containerPort: 1936 containerPort: 1936
resources: resources:
requests: {}
cpu: 100m
memory: 128Mi
env: env:
- name: EASYHAPROXY_DISCOVER - name: EASYHAPROXY_DISCOVER
value: kubernetes value: kubernetes
@ -206,3 +194,27 @@ spec:
value: DEBUG value: DEBUG
- name: CERTBOT_LOG_LEVEL - name: CERTBOT_LOG_LEVEL
value: DEBUG value: DEBUG
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: EASYHAPROXY_UPDATE_INGRESS_STATUS
value: "true"
- name: EASYHAPROXY_DEPLOYMENT_MODE
value: "auto"
- name: EASYHAPROXY_STATUS_UPDATE_INTERVAL
value: "30"
---
# Source: easyhaproxy/templates/ingressclass.yaml
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: easyhaproxy
labels:
helm.sh/chart: easyhaproxy-2.0.0
app.kubernetes.io/name: easyhaproxy
app.kubernetes.io/instance: ingress
app.kubernetes.io/version: "6.0.0"
app.kubernetes.io/managed-by: Helm
spec:
controller: byjg.com/easyhaproxy

View file

@ -28,7 +28,7 @@ Prevents disk space issues by automatically cleaning up temporary files created
### Static YAML Configuration ### Static YAML Configuration
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
plugins: plugins:
enabled: [cleanup] enabled: [cleanup]
config: config:
@ -53,7 +53,7 @@ Configure the Cleanup plugin globally:
### Custom Idle Time (1 hour) ### Custom Idle Time (1 hour)
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
plugins: plugins:
enabled: [cleanup] enabled: [cleanup]
config: config:

View file

@ -17,11 +17,11 @@ Cloudflare replaces the visitor's IP with its own. This plugin restores the orig
## Configuration Options ## Configuration Options
| Option | Description | Default | | Option | Description | Default |
|-------------------|------------------------------------------|-----------------------------------| |-------------------|------------------------------------------|---------------------------------------|
| `enabled` | Enable/disable plugin | `true` | | `enabled` | Enable/disable plugin | `true` |
| `use_builtin_ips` | Use built-in Cloudflare IP ranges | `true` | | `use_builtin_ips` | Use built-in Cloudflare IP ranges | `true` |
| `ip_list_path` | Path to Cloudflare IP list | `/etc/haproxy/cloudflare_ips.lst` | | `ip_list_path` | Path to Cloudflare IP list | `/etc/easyhaproxy/cloudflare_ips.lst` |
## Configuration Examples ## Configuration Examples
@ -55,7 +55,7 @@ kind: Ingress
metadata: metadata:
annotations: annotations:
easyhaproxy.plugins: "cloudflare" easyhaproxy.plugins: "cloudflare"
easyhaproxy.plugin.cloudflare.ip_list_path: "/etc/haproxy/cloudflare_ips.lst" easyhaproxy.plugin.cloudflare.ip_list_path: "/etc/easyhaproxy/cloudflare_ips.lst"
spec: spec:
rules: rules:
- host: example.com - host: example.com
@ -72,7 +72,7 @@ spec:
### Static YAML Configuration ### Static YAML Configuration
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
plugins: plugins:
config: config:
cloudflare: cloudflare:
@ -84,11 +84,11 @@ plugins:
Configure Cloudflare plugin defaults for all domains: Configure Cloudflare plugin defaults for all domains:
| Environment Variable | Config Key | Type | Default | Description | | Environment Variable | Config Key | Type | Default | Description |
|-------------------------------------------------|-------------------|----------|-----------------------------------|---------------------------------------| |-------------------------------------------------|-------------------|----------|---------------------------------------|---------------------------------------|
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | | `EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains |
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS` | `use_builtin_ips` | boolean | `true` | Use built-in Cloudflare IP ranges | | `EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS` | `use_builtin_ips` | boolean | `true` | Use built-in Cloudflare IP ranges |
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH` | `ip_list_path` | string | `/etc/haproxy/cloudflare_ips.lst` | Path to Cloudflare IP list file | | `EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH` | `ip_list_path` | string | `/etc/easyhaproxy/cloudflare_ips.lst` | Path to Cloudflare IP list file |
**Note:** Environment variables set defaults for ALL domains. To enable/disable per-domain, use container labels or Kubernetes annotations. **Note:** Environment variables set defaults for ALL domains. To enable/disable per-domain, use container labels or Kubernetes annotations.
@ -96,7 +96,7 @@ Configure Cloudflare plugin defaults for all domains:
```haproxy ```haproxy
# Cloudflare - Restore original visitor IP # Cloudflare - Restore original visitor IP
acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst acl from_cloudflare src -f /etc/easyhaproxy/cloudflare_ips.lst
http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare
``` ```
@ -114,7 +114,7 @@ The plugin includes the current Cloudflare IP ranges (22 ranges total):
- 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32 - 2400:cb00::/32, 2606:4700::/32, 2803:f800::/32, 2405:b500::/32
- 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32 - 2405:8100::/32, 2a06:98c0::/29, 2c0f:f248::/32
These ranges are automatically written to `/etc/haproxy/cloudflare_ips.lst` during each discovery cycle. These ranges are automatically written to `/etc/easyhaproxy/cloudflare_ips.lst` during each discovery cycle.
## Important Notes ## Important Notes

View file

@ -73,16 +73,14 @@ spec:
### Static YAML Configuration ### Static YAML Configuration
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
easymapping: containers:
- host: example.com "example.com:80":
port: 80 ip: ["webapp:80"]
container: webapp:80 plugins: [deny_pages]
plugins: plugin:
- deny_pages
plugin_config:
deny_pages: deny_pages:
paths: /admin,/private,/debug paths: [/admin, /private, /debug]
status_code: 403 status_code: 403
``` ```

View file

@ -20,7 +20,7 @@ Automatically generates HAProxy `fcgi-app` configuration that defines required C
| Option | Description | Default | | Option | Description | Default |
|-------------------|-----------------------------------------|------------------------------------| |-------------------|-----------------------------------------|------------------------------------|
| `enabled` | Enable/disable plugin | `true` | | `enabled` | Enable/disable plugin | `true` |
| `document_root` | Document root path | `/var/www/html` | | `document_root` | Document root path | `/etc/easyhaproxy/www` |
| `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) | | `script_filename` | Custom pattern for SCRIPT_FILENAME | `%[path]` (uses HAProxy's default) |
| `index_file` | Default index file | `index.php` | | `index_file` | Default index file | `index.php` |
| `path_info` | Enable PATH_INFO support | `true` | | `path_info` | Enable PATH_INFO support | `true` |
@ -40,10 +40,10 @@ services:
easyhaproxy.http.localport: 9000 easyhaproxy.http.localport: 9000
easyhaproxy.http.proto: fcgi easyhaproxy.http.proto: fcgi
easyhaproxy.http.plugins: fastcgi easyhaproxy.http.plugins: fastcgi
easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html easyhaproxy.http.plugin.fastcgi.document_root: /etc/easyhaproxy/www
easyhaproxy.http.plugin.fastcgi.index_file: index.php easyhaproxy.http.plugin.fastcgi.index_file: index.php
volumes: volumes:
- ./app:/var/www/html - ./app:/etc/easyhaproxy/www
``` ```
### Docker/Docker Compose (Unix socket) ### Docker/Docker Compose (Unix socket)
@ -57,10 +57,10 @@ services:
easyhaproxy.http.socket: /run/php/php-fpm.sock easyhaproxy.http.socket: /run/php/php-fpm.sock
easyhaproxy.http.proto: fcgi easyhaproxy.http.proto: fcgi
easyhaproxy.http.plugins: fastcgi easyhaproxy.http.plugins: fastcgi
easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html easyhaproxy.http.plugin.fastcgi.document_root: /etc/easyhaproxy/www
easyhaproxy.http.plugin.fastcgi.index_file: index.php easyhaproxy.http.plugin.fastcgi.index_file: index.php
volumes: volumes:
- ./app:/var/www/html - ./app:/etc/easyhaproxy/www
- /run/php:/run/php - /run/php:/run/php
``` ```
@ -82,7 +82,7 @@ kind: Ingress
metadata: metadata:
annotations: annotations:
easyhaproxy.plugins: "fastcgi" easyhaproxy.plugins: "fastcgi"
easyhaproxy.plugin.fastcgi.document_root: "/var/www/html" easyhaproxy.plugin.fastcgi.document_root: "/etc/easyhaproxy/www"
easyhaproxy.plugin.fastcgi.index_file: "index.php" easyhaproxy.plugin.fastcgi.index_file: "index.php"
spec: spec:
rules: rules:
@ -100,7 +100,7 @@ spec:
### Static YAML Configuration ### Static YAML Configuration
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
easymapping: easymapping:
- host: phpapp.local - host: phpapp.local
port: 80 port: 80
@ -110,7 +110,7 @@ easymapping:
- fastcgi - fastcgi
plugin_config: plugin_config:
fastcgi: fastcgi:
document_root: /var/www/html document_root: /etc/easyhaproxy/www
index_file: index.php index_file: index.php
path_info: true path_info: true
``` ```
@ -122,7 +122,7 @@ Configure FastCGI plugin defaults for all domains:
| Environment Variable | Config Key | Type | Default | Description | | Environment Variable | Config Key | Type | Default | Description |
|----------------------------------------------|-------------------|----------|-----------------|---------------------------------------| |----------------------------------------------|-------------------|----------|-----------------|---------------------------------------|
| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains | | `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains |
| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path | | `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/etc/easyhaproxy/www` | Document root path |
| `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME | | `EASYHAPROXY_PLUGIN_FASTCGI_SCRIPT_FILENAME` | `script_filename` | string | `%[path]` | Custom pattern for SCRIPT_FILENAME |
| `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file | | `EASYHAPROXY_PLUGIN_FASTCGI_INDEX_FILE` | `index_file` | string | `index.php` | Default index file |
| `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support | | `EASYHAPROXY_PLUGIN_FASTCGI_PATH_INFO` | `path_info` | boolean | `true` | Enable PATH_INFO support |
@ -136,7 +136,7 @@ The plugin generates a top-level `fcgi-app` section and a `use-fcgi-app` directi
```haproxy ```haproxy
# Top-level fcgi-app definition (added after defaults, before frontends/backends) # Top-level fcgi-app definition (added after defaults, before frontends/backends)
fcgi-app fcgi_phpapp_local fcgi-app fcgi_phpapp_local
docroot /var/www/html docroot /etc/easyhaproxy/www
index index.php index index.php
path-info ^(/.+\.php)(/.*)?$ path-info ^(/.+\.php)(/.*)?$

View file

@ -72,7 +72,7 @@ spec:
### Static YAML Configuration ### Static YAML Configuration
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
easymapping: easymapping:
- host: admin.example.com - host: admin.example.com
port: 443 port: 443

View file

@ -31,12 +31,22 @@ Protect APIs and services with JWT authentication without needing application-le
| `algorithm` | JWT signing algorithm | `RS256` | | `algorithm` | JWT signing algorithm | `RS256` |
| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) | | `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) | | `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) |
| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) | | `pubkey_path` | Path to public key file (priority 1: explicit file path) | (optional) |
| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (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) | | `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` | | `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` | | `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 ## Path Validation Logic
- **No paths configured:** ALL requests to the domain require JWT validation (default behavior) - **No paths configured:** ALL requests to the domain require JWT validation (default behavior)
@ -67,9 +77,9 @@ services:
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ 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.audience: https://api.example.com
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
volumes: volumes:
- ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro - ./pubkey.pem:/etc/easyhaproxy/jwt_keys/api_pubkey.pem:ro
``` ```
### Protect Specific Paths Only ### Protect Specific Paths Only
@ -77,7 +87,7 @@ services:
```yaml ```yaml
labels: labels:
easyhaproxy.http.plugins: jwt_validator easyhaproxy.http.plugins: jwt_validator
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem 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.paths: /api/admin,/api/sensitive
easyhaproxy.http.plugin.jwt_validator.only_paths: false easyhaproxy.http.plugin.jwt_validator.only_paths: false
# /api/health, /api/docs, etc. remain publicly accessible # /api/health, /api/docs, etc. remain publicly accessible
@ -88,7 +98,7 @@ labels:
```yaml ```yaml
labels: labels:
easyhaproxy.http.plugins: jwt_validator easyhaproxy.http.plugins: jwt_validator
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem 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.paths: /api/public,/api/v1
easyhaproxy.http.plugin.jwt_validator.only_paths: true easyhaproxy.http.plugin.jwt_validator.only_paths: true
# All paths except /api/public and /api/v1 are denied # All paths except /api/public and /api/v1 are denied
@ -100,7 +110,7 @@ labels:
labels: labels:
easyhaproxy.http.plugin.jwt_validator.issuer: none easyhaproxy.http.plugin.jwt_validator.issuer: none
easyhaproxy.http.plugin.jwt_validator.audience: none easyhaproxy.http.plugin.jwt_validator.audience: none
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
``` ```
### Allow Anonymous Access (Optional JWT) ### Allow Anonymous Access (Optional JWT)
@ -111,16 +121,79 @@ services:
labels: labels:
easyhaproxy.http.host: api.example.com easyhaproxy.http.host: api.example.com
easyhaproxy.http.plugins: jwt_validator easyhaproxy.http.plugins: jwt_validator
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
easyhaproxy.http.plugin.jwt_validator.allow_anonymous: true easyhaproxy.http.plugin.jwt_validator.allow_anonymous: true
volumes: volumes:
- ./pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro - ./pubkey.pem:/etc/easyhaproxy/jwt_keys/api_pubkey.pem:ro
# Requests without Authorization header are allowed # Requests without Authorization header are allowed
# Requests with Authorization header are validated # Requests with Authorization header are validated
# Invalid JWTs are rejected # Invalid JWTs are rejected
``` ```
### Kubernetes Annotations ### 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 ```yaml
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
@ -131,15 +204,17 @@ metadata:
easyhaproxy.plugin.jwt_validator.algorithm: "RS256" easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/easyhaproxy/jwt_keys/api_pubkey.pem"
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
easyhaproxy.plugin.jwt_validator.only_paths: "false" easyhaproxy.plugin.jwt_validator.only_paths: "false"
spec: spec:
ingressClassName: easyhaproxy
rules: rules:
- host: api.example.com - host: api.example.com
http: http:
paths: paths:
- path: / - path: /
pathType: Prefix
backend: backend:
service: service:
name: api-service name: api-service
@ -147,22 +222,23 @@ spec:
number: 8080 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 ### Static YAML Configuration
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
easymapping: containers:
- host: api.example.com "api.example.com:443":
port: 443 ip: ["api-service:8080"]
container: api-service:8080 ssl: true
plugins: plugins: [jwt_validator]
- jwt_validator plugin:
plugin_config:
jwt_validator: jwt_validator:
algorithm: RS256 algorithm: RS256
issuer: https://auth.example.com/ issuer: https://auth.example.com/
audience: https://api.example.com audience: https://api.example.com
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
``` ```
### Environment Variables ### Environment Variables
@ -201,7 +277,7 @@ http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
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 '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 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 audience' unless { var(txn.aud) -m str https://api.example.com }
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } 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 # Validate expiration
http-request set-var(txn.now) date() http-request set-var(txn.now) date()
@ -227,7 +303,7 @@ http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
# Validate JWT (only on protected paths) # 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 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path 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 # Validate expiration
http-request set-var(txn.now) date() if jwt_protected_path http-request set-var(txn.now) date() if jwt_protected_path
@ -256,7 +332,7 @@ 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) # 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 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 }
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } 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 # Validate expiration
http-request set-var(txn.now) date() http-request set-var(txn.now) date()
@ -280,7 +356,7 @@ http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
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 '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 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 audience' unless { var(txn.aud) -m str https://api.example.com } if { req.hdr(authorization) -m found }
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if { req.hdr(authorization) -m found } 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) # Validate expiration (only if Authorization header is present)
http-request set-var(txn.now) date() if { req.hdr(authorization) -m found } http-request set-var(txn.now) date() if { req.hdr(authorization) -m found }

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 10 sidebar_position: 11
--- ---
# SSL - Automatic Certificate Management Environment (ACME) # SSL - Automatic Certificate Management Environment (ACME)
@ -33,7 +33,7 @@ At a high level, ACME with Easy HAProxy works in two stages:
- Manually setting `EASYHAPROXY_CERTBOT_SERVER` (and `EASYHAPROXY_CERTBOT_EAB_KID` / `EASYHAPROXY_CERTBOT_EAB_HMAC_KEY` when your CA requires EAB). - Manually setting `EASYHAPROXY_CERTBOT_SERVER` (and `EASYHAPROXY_CERTBOT_EAB_KID` / `EASYHAPROXY_CERTBOT_EAB_HMAC_KEY` when your CA requires EAB).
- Always set your contact email via `EASYHAPROXY_CERTBOT_EMAIL`. - Always set your contact email via `EASYHAPROXY_CERTBOT_EMAIL`.
- Ensure ports 80 and 443 are publicly reachable on the EasyHAProxy host. - Ensure ports 80 and 443 are publicly reachable on the EasyHAProxy host.
- Persist the folder `/certs/certbot` on a durable volume so issued/renewed certificates survive container restarts and avoid hitting CA rate limits. - Persist the folder `/etc/easyhaproxy/certs/certbot` on a durable volume so issued/renewed certificates survive container restarts and avoid hitting CA rate limits.
- Challenge method is HTTP-01 only; EasyHAProxy configures a standalone Certbot responder internally. - Challenge method is HTTP-01 only; EasyHAProxy configures a standalone Certbot responder internally.
2. Enable ACME per domain (per service/app) 2. Enable ACME per domain (per service/app)
@ -44,7 +44,7 @@ At a high level, ACME with Easy HAProxy works in two stages:
What happens under the hood What happens under the hood
- When a labeled domain is detected and a certificate is needed, EasyHAProxy runs Certbot with `--preferred-challenges http` and a standalone responder bound to internal port 2080. - When a labeled domain is detected and a certificate is needed, EasyHAProxy runs Certbot with `--preferred-challenges http` and a standalone responder bound to internal port 2080.
- HAProxy temporarily routes `/.well-known/acme-challenge/` for that domain to the Certbot responder, allowing the CA to validate via HTTP-01. - HAProxy temporarily routes `/.well-known/acme-challenge/` for that domain to the Certbot responder, allowing the CA to validate via HTTP-01.
- On success, EasyHAProxy merges the issued cert and key and stores them under `/certs/certbot` (one PEM per domain), then reloads HAProxy to serve HTTPS for that domain. - On success, EasyHAProxy merges the issued cert and key and stores them under `/etc/easyhaproxy/certs/certbot` (one PEM per domain), then reloads HAProxy to serve HTTPS for that domain.
- Certificates are monitored and renewed automatically before expiry. - Certificates are monitored and renewed automatically before expiry.
Tips Tips
@ -54,19 +54,21 @@ Tips
## Environment Variables ## Environment Variables
To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up to the following environment variables: To enable the ACME protocol we need to enable Certbot in EasyHAProxy by setting up the following environment variables:
| Environment Variable | Required? | Description | | Environment Variable | Required? | Description |
|------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------| |------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
| EASYHAPROXY_CERTBOT_EMAIL | YES | Your email in the certificate authority. | | EASYHAPROXY_CERTBOT_EMAIL | **YES** | Your email for the certificate authority. Required for certificate issuance. |
| EASYHAPROXY_CERTBOT_AUTOCONFIG | - | Will use pre-sets for your Certificate Authority (CA). See table below. | | EASYHAPROXY_CERTBOT_AUTOCONFIG | **YES\*** | Pre-configured settings for your Certificate Authority (CA). See table below. **Required if CERTBOT_SERVER is not set.** |
| EASYHAPROXY_CERTBOT_SERVER | - | The ACME Endpoint of your certificate authority. If you use AUTOCONFIG, it is set automatically. See table below. | | EASYHAPROXY_CERTBOT_SERVER | **YES\*** | The ACME endpoint URL of your certificate authority. **Required if AUTOCONFIG is not set.** Auto-set when using AUTOCONFIG. |
| EASYHAPROXY_CERTBOT_EAB_KID | - | External Account Binding (EAB) Key Identifier (KID) provided by your certificate authority. Some CA require it. See table below. | | EASYHAPROXY_CERTBOT_EAB_KID | - | External Account Binding (EAB) Key Identifier (KID) provided by your certificate authority. Some CA require it. See table below. |
| EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | - | External Account Binding (EAB) HMAC Key provided by your certificate authority. Some CA require it. See table below. | | EASYHAPROXY_CERTBOT_EAB_HMAC_KEY | - | External Account Binding (EAB) HMAC Key provided by your certificate authority. Some CA require it. See table below. |
| EASYHAPROXY_CERTBOT_RETRY_COUNT | - | Wait 'n' requests before retrying issue invalid requests. Default 60. | | EASYHAPROXY_CERTBOT_RETRY_COUNT | - | Wait 'n' requests before retrying issue invalid requests. Default 60. |
| EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES | - | The preferred challenges for Certbot. Available: `http` | | EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES | - | The preferred challenges for Certbot. Available: `http` |
| EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK | - | The path to a script that will be executed (default: None) | | EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK | - | The path to a script that will be executed (default: None) |
**\*Important:** You must set **either** `EASYHAPROXY_CERTBOT_AUTOCONFIG` **or** `EASYHAPROXY_CERTBOT_SERVER` (not both). Using `AUTOCONFIG` is recommended as it automatically configures the server URL for popular certificate authorities.
## Auto Config Certificate Authority (CA) ## Auto Config Certificate Authority (CA)
Here are detailed instructions per Certificate Authority (CA). If anyone is missing, please let's know. Here are detailed instructions per Certificate Authority (CA). If anyone is missing, please let's know.
@ -105,7 +107,7 @@ docker run \
-e EASYHAPROXY_CERTBOT_EMAIL=john@doe.com \ -e EASYHAPROXY_CERTBOT_EMAIL=john@doe.com \
-p 80:80 \ -p 80:80 \
-p 443:443 \ -p 443:443 \
-v /path/to/guest/certbot/certs:/certs/certbot \ -v /path/to/guest/certbot/certs:/etc/easyhaproxy/certs/certbot \
... \ ... \
byjg/easy-haproxy byjg/easy-haproxy
``` ```
@ -118,7 +120,7 @@ docker run \
:::danger Important: Persist Certbot Certificates :::danger Important: Persist Certbot Certificates
To avoid hitting rate limits and certificate issuing problems: To avoid hitting rate limits and certificate issuing problems:
- **You must persist** the container folder `/certs/certbot` outside the container - **You must persist** the container folder `/etc/easyhaproxy/certs/certbot` outside the container
- **Never delete or modify** its contents manually - **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 don't persist this folder, or if you delete/modify its contents, certificate issuing may not work properly and you may hit rate limits
::: :::
@ -147,5 +149,146 @@ docker run \
- Do not set port 443 for the container when using ACME, because EasyHAProxy will create the HTTPS binding automatically once the certificate is issued. - Do not set port 443 for the container when using ACME, because EasyHAProxy will create the HTTPS binding automatically once the certificate is issued.
::: :::
## Complete Docker Compose Example
Here's a complete `docker-compose.yml` showing proper ACME configuration:
```yaml
services:
easyhaproxy:
image: byjg/easy-haproxy:6.0.0
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# REQUIRED: Persist Certbot certificates (ACME)
- certs_certbot:/etc/easyhaproxy/certs/certbot
# OPTIONAL: For manual certificates (see SSL documentation)
- certs_haproxy:/etc/easyhaproxy/certs/haproxy
environment:
# Service discovery
EASYHAPROXY_DISCOVER: docker
EASYHAPROXY_LABEL_PREFIX: easyhaproxy
# ACME/Certbot Configuration (Method 1: Recommended)
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"
HAPROXY_USERNAME: admin
HAPROXY_PASSWORD: password
HAPROXY_STATS_PORT: 1936
ports:
- "80:80/tcp"
- "443:443/tcp"
- "1936:1936/tcp"
healthcheck:
test: ["CMD", "curl", "-f", "-u", "admin:password", "http://localhost:1936"]
interval: 10s
timeout: 5s
start_period: 30s
retries: 3
# Example backend service with ACME enabled
myapp:
image: nginx:alpine
labels:
easyhaproxy.http.host: example.com
easyhaproxy.http.port: 80
easyhaproxy.http.localport: 80
easyhaproxy.http.certbot: "true" # Enable ACME for this domain
volumes:
certs_certbot:
# This volume MUST be persisted to avoid rate limits
certs_haproxy:
# Optional: only needed if using manual certificates
```
## Certificate Storage Paths
EasyHAProxy uses different paths for different certificate types:
| Path | Purpose | When to Mount |
|----------------------------------|-------------------------------------|----------------------------------------------|
| `/etc/easyhaproxy/certs/certbot` | ACME/Certbot automatic certificates | **Required** when using ACME |
| `/etc/easyhaproxy/certs/haproxy` | Manual/custom certificates | Optional - only if using custom certificates |
Both volumes can be mounted simultaneously. Per-domain certificate selection:
- If a domain has `certbot=true` label, ACME certificate is used
- Otherwise, manual certificate from `/etc/easyhaproxy/certs/haproxy` is used (if present)
## Troubleshooting
### Warning: "ACME environment not ready: ACME server not configured"
**Cause:** You set `EASYHAPROXY_CERTBOT_EMAIL` but forgot to configure the ACME server.
**Solution:** Add one of these to your environment variables:
```yaml
# Option 1: Use AUTOCONFIG (recommended)
EASYHAPROXY_CERTBOT_AUTOCONFIG: letsencrypt
# Option 2: Set server manually
EASYHAPROXY_CERTBOT_SERVER: https://acme-v02.api.letsencrypt.org/directory
```
### Certificates Not Being Issued
**Common causes:**
1. Port 80 is not publicly accessible
2. DNS doesn't point to your server
3. Container label missing `certbot=true`
4. Container port is not 80 (`easyhaproxy.<definition>.port` must be 80)
5. Rate limits hit (check `/etc/easyhaproxy/certs/certbot` volume)
**Debug steps:**
```bash
# Check EasyHAProxy logs
docker logs easyhaproxy
# Check if Certbot volume is persisted
docker volume inspect certs_certbot
# Verify port 80 is accessible
curl -I http://your-domain.com/.well-known/acme-challenge/test
```
### Rate Limit Errors
If you hit Let's Encrypt rate limits:
- Wait for the limit window to reset (usually 1 week)
- Use staging server for testing: `EASYHAPROXY_CERTBOT_AUTOCONFIG: letsencrypt_test`
- 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) [Open source ByJG](http://opensource.byjg.com)

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 11 sidebar_position: 12
--- ---
# Container Labels # Container Labels
@ -134,6 +134,8 @@ backend srv_phpapp_local_80
option forwardfor option forwardfor
http-request set-header X-Forwarded-Port %[dst_port] http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc } http-request add-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
http-request set-header X-Request-ID %[uuid()]
server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi server srv-0 /run/php/php-fpm.sock check weight 1 proto fcgi
``` ```

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 8 sidebar_position: 9
--- ---
# DigitalOcean # DigitalOcean

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 7 sidebar_position: 8
--- ---
# Dokku # Dokku

View file

@ -1,23 +1,25 @@
--- ---
sidebar_position: 12 sidebar_position: 13
--- ---
# Docker environment variables # Docker environment variables
| Environment Variable | Description | Default | | Environment Variable | Description | Default |
|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------| |---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|
| EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** | | EASYHAPROXY_DISCOVER | How the services will be discovered to create `haproxy.cfg`: `static`, `docker`, `swarm` or `kubernetes` | **required** |
| EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` | | EASYHAPROXY_LABEL_PREFIX | (Optional) The key will search for matching resources. | `easyhaproxy` |
| EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* | | EASYHAPROXY_BASE_PATH | (Optional) Base directory for all EasyHAProxy files. All paths (config, certs, plugins, www) are constructed relative to this base. | `/etc/easyhaproxy` |
| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` | | EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* |
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 | | EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | | EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG | | EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO | | CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` | | HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO |
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* | | HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` | | HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` | | HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` |
| HAPROXY_STATS_CORS_ORIGIN | (Optional) Enable CORS for the HAProxy stats dashboard by specifying the allowed origin (e.g., `http://localhost:3000`). Only applies when `HAPROXY_PASSWORD` is defined. | *empty* |
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` |
:::tip HAProxy Stats :::tip HAProxy Stats
Statistics are only configured when `HAPROXY_PASSWORD` is set. Without a password, the stats section is not generated. Statistics are only configured when `HAPROXY_PASSWORD` is set. Without a password, the stats section is not generated.

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 5 sidebar_position: 6
--- ---
# Helm 3 # Helm 3

View file

@ -7,9 +7,10 @@ sidebar_position: 1
## Setup Kubernetes EasyHAProxy ## Setup Kubernetes EasyHAProxy
:::info How it works :::info How it works
EasyHAProxy for Kubernetes operates by querying all ingress definitions with the annotation EasyHAProxy for Kubernetes operates by querying all ingress definitions with either the
`kubernetes.io/ingress.class: easyhaproxy-ingress`. Upon finding this annotation, `spec.ingressClassName: easyhaproxy` field (recommended) or the deprecated annotation
EasyHAProxy immediately sets up HAProxy and begins serving traffic. `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: For Kubernetes installations, there are three available installation modes:
@ -47,25 +48,25 @@ kubectl label nodes node-01 "easyhaproxy/node=master"
kubectl create namespace easyhaproxy kubectl create namespace easyhaproxy
kubectl apply -f \ kubectl apply -f \
https://raw.githubusercontent.com/byjg/docker-easy-haproxy/5.0.0/deploy/kubernetes/easyhaproxy-daemonset.yml 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) 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 ## Running containers
Your container only requires creating an ingress with the annotation `kubernetes.io/ingress.class: easyhaproxy-ingress` pointing to your service. Your container only requires creating an ingress with the `spec.ingressClassName: easyhaproxy` field pointing to your service.
e.g. e.g.
```yaml ```yaml
kind: Ingress kind: Ingress
metadata: metadata:
annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
name: example-ingress name: example-ingress
namespace: example namespace: example
spec: spec:
# Use ingressClassName (recommended)
ingressClassName: easyhaproxy
rules: rules:
- host: example.org - host: example.org
http: http:
@ -78,6 +79,10 @@ spec:
pathType: ImplementationSpecific 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. 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. You don't need to expose any port in your container.
@ -92,7 +97,7 @@ You don't need to expose any port in your container.
| annotation | Description | Default | Example | | annotation | Description | Default | Example |
|-------------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------| |-------------------------------------|-------------------------------------------------------------------------------------|--------------|----------------------------|
| kubernetes.io/ingress.class | (required) Activate EasyHAProxy. | **required** | easyhaproxy-ingress | | 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.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.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.redirect | (optional) JSON. Key pair with a domain and its destination. | *empty* | \{"domain":"redirect_url"} |
@ -116,11 +121,11 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.plugins: "cloudflare,deny_pages" easyhaproxy.plugins: "cloudflare,deny_pages"
name: example-ingress name: example-ingress
namespace: example namespace: example
spec: spec:
ingressClassName: easyhaproxy
rules: rules:
- host: example.org - host: example.org
http: http:
@ -142,13 +147,13 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.plugins: "deny_pages" easyhaproxy.plugins: "deny_pages"
easyhaproxy.plugin.deny_pages.paths: "/admin,/private,/config" easyhaproxy.plugin.deny_pages.paths: "/admin,/private,/config"
easyhaproxy.plugin.deny_pages.status_code: "403" easyhaproxy.plugin.deny_pages.status_code: "403"
name: secure-app-ingress name: secure-app-ingress
namespace: production namespace: production
spec: spec:
ingressClassName: easyhaproxy
rules: rules:
- host: myapp.example.com - host: myapp.example.com
http: http:
@ -168,12 +173,13 @@ spec:
```yaml ```yaml
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.plugins: "jwt_validator" easyhaproxy.plugins: "jwt_validator"
easyhaproxy.plugin.jwt_validator.algorithm: "RS256" easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" easyhaproxy.plugin.jwt_validator.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. **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.
@ -183,10 +189,11 @@ metadata:
```yaml ```yaml
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.plugins: "ip_whitelist" easyhaproxy.plugins: "ip_whitelist"
easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5" easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5"
easyhaproxy.plugin.ip_whitelist.status_code: "403" easyhaproxy.plugin.ip_whitelist.status_code: "403"
spec:
ingressClassName: easyhaproxy
``` ```
**Restore Cloudflare visitor IPs:** **Restore Cloudflare visitor IPs:**
@ -194,8 +201,9 @@ metadata:
```yaml ```yaml
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.plugins: "cloudflare" easyhaproxy.plugins: "cloudflare"
spec:
ingressClassName: easyhaproxy
``` ```
**Multiple plugins together:** **Multiple plugins together:**
@ -203,10 +211,11 @@ metadata:
```yaml ```yaml
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.plugins: "cloudflare,deny_pages" easyhaproxy.plugins: "cloudflare,deny_pages"
easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php" easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php"
easyhaproxy.plugin.deny_pages.status_code: "404" easyhaproxy.plugin.deny_pages.status_code: "404"
spec:
ingressClassName: easyhaproxy
``` ```
### Global Plugin Configuration ### Global Plugin Configuration
@ -236,19 +245,232 @@ env:
For more information on plugin types and available plugins, see the [Using Plugins](plugins.md) guide. 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: "<base64-encoded-content>"
```
### 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 ## Certbot / ACME / Letsencrypt
It is necessary add the annotation `easyhaproxy.certbot` to the ingress configuration: It is necessary to add the annotation `easyhaproxy.certbot` to the ingress configuration:
```yaml ```yaml
kind: Ingress kind: Ingress
metadata: metadata:
annotations: annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
easyhaproxy.certbot: 'true' easyhaproxy.certbot: 'true'
name: example-ingress name: example-ingress
namespace: example namespace: example
spec: spec:
ingressClassName: easyhaproxy
.... ....
``` ```
@ -276,11 +498,10 @@ type: kubernetes.io/tls
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
annotations:
kubernetes.io/ingress.class: easyhaproxy-ingress
name: tls-example name: tls-example
namespace: default namespace: default
spec: spec:
ingressClassName: easyhaproxy
tls: tls:
- hosts: - hosts:
- host2.local - host2.local

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 6 sidebar_position: 7
--- ---
# Microk8s Add-ons # Microk8s Add-ons

View file

@ -4,6 +4,26 @@ sidebar_position: 22
# Other configurations # Other configurations
## Proxy Headers
EasyHAProxy automatically sets standard proxy-awareness headers for all HTTP requests:
| Header | Description | Example Value |
|--------|-------------|---------------|
| X-Forwarded-For | Client IP address | `203.0.113.50` |
| X-Forwarded-Port | Port HAProxy received request on | `443` |
| X-Forwarded-Proto | Protocol (http or https) | `https` |
| X-Forwarded-Host | Original Host header from client | `example.com` |
| X-Request-ID | Unique request identifier (UUID) | `550e8400-e29b-41d4-a716-446655440000` |
These headers help backend applications:
- Determine the original client IP
- Detect HTTPS vs HTTP
- Generate correct URLs with proper hostname
- Correlate requests for debugging and monitoring
**Note:** Headers are only added in HTTP mode, not TCP mode.
## Exposing Ports ## Exposing Ports
Some ports on the EasyHAProxy container and in the firewall are required to be open. However, you don't need to expose the other container ports because EasyHAProxy will handle that. Some ports on the EasyHAProxy container and in the firewall are required to be open. However, you don't need to expose the other container ports because EasyHAProxy will handle that.
@ -36,18 +56,18 @@ docker run \
## Mapping custom .cfg files ## Mapping custom .cfg files
You can concatenate valid HAProxy `.cfg` files to the dynamically generated `haproxy.cfg` by mapping the folder `/etc/haproxy/conf.d`. You can concatenate valid HAProxy `.cfg` files to the dynamically generated `haproxy.cfg` by mapping the folder `/etc/easyhaproxy/haproxy/conf.d`.
```bash title="Mount custom config directory" ```bash title="Mount custom config directory"
docker run \ docker run \
/* other parameters */ /* other parameters */
-v /your/local/conf.d:/etc/haproxy/conf.d \ -v /your/local/conf.d:/etc/easyhaproxy/haproxy/conf.d \
-d byjg/easy-haproxy -d byjg/easy-haproxy
``` ```
## Setting Custom Errors ## Setting Custom Errors
If enabled, map the volume : `/etc/haproxy/errors-custom/` to your container and put a file named `ERROR_NUMBER.http` If enabled, map the volume : `/etc/easyhaproxy/haproxy/errors-custom/` to your container and put a file named `ERROR_NUMBER.http`
where ERROR_NUMBER is the HTTP error code (e.g., `503.http`) where ERROR_NUMBER is the HTTP error code (e.g., `503.http`)
---- ----

211
docs/pip.md Normal file
View file

@ -0,0 +1,211 @@
---
sidebar_position: 5
---
# Install via pip / uv
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';
<Tabs>
<TabItem value="debian" label="Debian / Ubuntu" default>
```bash
sudo apt install haproxy
```
</TabItem>
<TabItem value="rhel" label="RHEL / Fedora">
```bash
sudo dnf install haproxy
```
</TabItem>
<TabItem value="macos" label="macOS">
```bash
brew install haproxy
```
</TabItem>
</Tabs>
## 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.
```
easy-haproxy [OPTIONS]
```
### Core
| Flag | Environment Variable | Default | Description |
|--------------------------|----------------------------|------------------------------------------------------|-----------------------------------------------------------|
| `--discover MODE` | `EASYHAPROXY_DISCOVER` | **required** | Discovery mode: `static`, `docker`, `swarm`, `kubernetes` |
| `--base-path PATH` | `EASYHAPROXY_BASE_PATH` | `/etc/easyhaproxy` (root) `~/easyhaproxy` (non-root) | Base directory for all EasyHAProxy files |
| `--label-prefix PREFIX` | `EASYHAPROXY_LABEL_PREFIX` | `easyhaproxy` | Label/annotation prefix used to discover services |
| `--ssl-mode MODE` | `EASYHAPROXY_SSL_MODE` | `default` | TLS policy: `strict`, `default`, or `loose` |
| `--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
| Flag | Environment Variable | Default | Description |
|-----------------------------|-------------------------|----------|---------------------------|
| `--log-level LEVEL` | `EASYHAPROXY_LOG_LEVEL` | `DEBUG` | EasyHAProxy log level |
| `--haproxy-log-level LEVEL` | `HAPROXY_LOG_LEVEL` | `INFO` | HAProxy process log level |
| `--certbot-log-level LEVEL` | `CERTBOT_LOG_LEVEL` | `DEBUG` | Certbot log level |
Valid levels: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, `FATAL`
### Stats Dashboard
| Flag | Environment Variable | Default | Description |
|--------------------------------------|-----------------------------|--------------|---------------------------------------------|
| `--haproxy-password PASSWORD` | `HAPROXY_PASSWORD` | *(disabled)* | Enable stats dashboard with this password |
| `--haproxy-username USERNAME` | `HAPROXY_USERNAME` | `admin` | Stats dashboard username |
| `--haproxy-stats-port PORT` | `HAPROXY_STATS_PORT` | `1936` | Stats dashboard port |
| `--haproxy-stats-cors-origin ORIGIN` | `HAPROXY_STATS_CORS_ORIGIN` | *(none)* | Allowed CORS origin for the stats dashboard |
:::tip
The stats dashboard is only enabled when `--haproxy-password` (or `HAPROXY_PASSWORD`) is set.
:::
### ACME / Certbot (SSL certificates)
| Flag | Environment Variable | Default | Description |
|---------------------------------------|--------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `--certbot-email EMAIL` | `EASYHAPROXY_CERTBOT_EMAIL` | *(none)* | Contact email — enables ACME when set |
| `--certbot-autoconfig CA` | `EASYHAPROXY_CERTBOT_AUTOCONFIG` | *(none)* | Well-known CA shorthand: `letsencrypt`, `letsencrypt_test`, `buypass`, `buypass_test`, `sslcom_rca`, `sslcom_ecc`, `google`, `google_test`, `zerossl` |
| `--certbot-server URL` | `EASYHAPROXY_CERTBOT_SERVER` | *(none)* | Custom ACME server directory URL |
| `--certbot-eab-kid KID` | `EASYHAPROXY_CERTBOT_EAB_KID` | *(none)* | External Account Binding key ID |
| `--certbot-eab-hmac-key KEY` | `EASYHAPROXY_CERTBOT_EAB_HMAC_KEY` | *(none)* | External Account Binding HMAC key |
| `--certbot-retry-count N` | `EASYHAPROXY_CERTBOT_RETRY_COUNT` | `60` | Iterations before retrying after a rate limit |
| `--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.
### 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.
### Kubernetes
| Flag | Environment Variable | Default | Description |
|--------------------------------------------|--------------------------------------|----------|----------------------------------------------|
| `--update-ingress-status BOOL` | `EASYHAPROXY_UPDATE_INGRESS_STATUS` | `true` | Update Ingress status with load-balancer IP |
| `--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 <<EOF
containers:
"myapp.example.com:80":
ip: ["127.0.0.1:3000"]
EOF
easy-haproxy --discover static
```
### Static mode with stats and HTTPS redirect
```bash
easy-haproxy \
--discover static \
--haproxy-password mysecret \
--ssl-mode default \
--log-level INFO
```
### Let's Encrypt (ACME)
```bash
easy-haproxy \
--discover static \
--certbot-email admin@example.com \
--certbot-autoconfig letsencrypt
```
## Running as a systemd service
To keep `easy-haproxy` running across reboots, create a systemd unit:
```ini title="/etc/systemd/system/easy-haproxy.service"
[Unit]
Description=EasyHAProxy
After=network.target
[Service]
ExecStart=/usr/local/bin/easy-haproxy --discover static --haproxy-password mysecret
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now easy-haproxy
```
:::tip Adjust ExecStart path
Run `which easy-haproxy` to get the correct binary path for `ExecStart`. If you installed with `uv tool`, it is typically `/root/.local/bin/easy-haproxy` when running as root.
:::
----
[Open source ByJG](http://opensource.byjg.com)

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 15 sidebar_position: 16
--- ---
# Plugin Development Guide # Plugin Development Guide
@ -101,7 +101,13 @@ Execute **once per discovered domain/host**.
├─ Calls plugin.configure(config) for each plugin ├─ Calls plugin.configure(config) for each plugin
└─ Validates configuration (plugin responsibility) └─ Validates configuration (plugin responsibility)
3. EXECUTION PHASE (per discovery cycle) 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 ├─ GLOBAL PLUGINS
│ └─ Executes all global plugins once │ └─ Executes all global plugins once
@ -109,9 +115,11 @@ Execute **once per discovered domain/host**.
└─ For each discovered domain: └─ For each discovered domain:
└─ Executes all domain plugins └─ Executes all domain plugins
4. RESULT PROCESSING 5. RESULT PROCESSING
├─ Collects PluginResult from each plugin ├─ Collects PluginResult from each plugin
├─ Injects haproxy_config into generated config ├─ Injects haproxy_config into backend sections
├─ Injects global_configs into global section
├─ Injects defaults_configs into defaults section
├─ Applies modified_easymapping if provided ├─ Applies modified_easymapping if provided
└─ Logs metadata for debugging └─ Logs metadata for debugging
``` ```
@ -119,7 +127,7 @@ Execute **once per discovered domain/host**.
### Plugin Loading Order ### Plugin Loading Order
1. **Builtin plugins** - Loaded from `/src/plugins/builtin/` 1. **Builtin plugins** - Loaded from `/src/plugins/builtin/`
2. **External plugins** - Loaded from `/etc/haproxy/plugins/` 2. **External plugins** - Loaded from `/etc/easyhaproxy/plugins/`
Plugins are discovered automatically by filename (`*.py` excluding `__*.py`). Plugins are discovered automatically by filename (`*.py` excluding `__*.py`).
@ -152,10 +160,10 @@ HAProxy Reload
### Step 1: Create Plugin File ### Step 1: Create Plugin File
Create a new Python file in `/etc/haproxy/plugins/` (or builtin location for core plugins): Create a new Python file in `/etc/easyhaproxy/plugins/` (or builtin location for core plugins):
```python ```python
# /etc/haproxy/plugins/my_plugin.py # /etc/easyhaproxy/plugins/my_plugin.py
import os import os
import sys import sys
@ -164,7 +172,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
class MyPlugin(PluginInterface): class MyPlugin(PluginInterface):
@ -240,7 +248,7 @@ services:
**Via YAML configuration:** **Via YAML configuration:**
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
plugins: plugins:
enabled: [my_plugin] enabled: [my_plugin]
config: config:
@ -306,6 +314,18 @@ class PluginInterface(ABC):
""" """
pass 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 @abstractmethod
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
""" """
@ -328,6 +348,7 @@ class PluginInterface(ABC):
**Methods:** **Methods:**
- `configure(config)` - Receives plugin configuration during initialization - `configure(config)` - Receives plugin configuration during initialization
- `initialize()` - **[New in v2.0]** Request file system resources (optional)
- `process(context)` - Main execution logic, returns `PluginResult` - `process(context)` - Main execution logic, returns `PluginResult`
### PluginType ### PluginType
@ -390,6 +411,71 @@ def process(self, context: PluginContext) -> PluginResult:
custom_label = context.host_config.get("custom_label", "default") 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 ### PluginResult
Plugin execution result containing configuration and metadata. Plugin execution result containing configuration and metadata.
@ -401,6 +487,8 @@ class PluginResult:
haproxy_config: str = "" # HAProxy config snippet to inject haproxy_config: str = "" # HAProxy config snippet to inject
modified_easymapping: Optional[list] = None # Modified easymapping structure modified_easymapping: Optional[list] = None # Modified easymapping structure
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging 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:** **Fields:**
@ -408,11 +496,13 @@ class PluginResult:
- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend) - `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend)
- `modified_easymapping` - Modified easymapping structure (optional, advanced use) - `modified_easymapping` - Modified easymapping structure (optional, advanced use)
- `metadata` - Dictionary with debugging/logging information - `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:** **Examples:**
```python ```python
# Simple config injection # Simple config injection (backend-level)
return PluginResult( return PluginResult(
haproxy_config="http-request deny deny_status 403" haproxy_config="http-request deny deny_status 403"
) )
@ -427,6 +517,22 @@ return PluginResult(
} }
) )
# 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) # No operation (plugin disabled or no action needed)
return PluginResult() return PluginResult()
``` ```
@ -439,12 +545,13 @@ Manages plugin loading, configuration, and execution.
class PluginManager: class PluginManager:
"""Manages plugin loading, configuration, and execution""" """Manages plugin loading, configuration, and execution"""
def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False): def __init__(self, plugins_dir: str | None = None, abort_on_error: bool = False):
""" """
Initialize the plugin manager Initialize the plugin manager
Args: Args:
plugins_dir: Directory containing plugin files 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 abort_on_error: If True, abort on plugin errors; if False, log and continue
""" """
@ -454,6 +561,9 @@ class PluginManager:
def configure_plugins(self, plugins_config: dict) -> None: def configure_plugins(self, plugins_config: dict) -> None:
"""Configure all loaded plugins with their settings""" """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]: def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
"""Execute all global plugins""" """Execute all global plugins"""
@ -461,6 +571,10 @@ class PluginManager:
"""Execute all domain plugins for a specific domain""" """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. **Note:** You typically don't interact with PluginManager directly when writing plugins. It's used by EasyHAProxy core.
--- ---
@ -598,7 +712,7 @@ The plugin creates:
Configuration: Configuration:
- enabled: Enable/disable the plugin (default: true) - enabled: Enable/disable the plugin (default: true)
- document_root: Document root path (default: /var/www/html) - document_root: Document root path (default: /etc/easyhaproxy/www)
- script_filename: Pattern for SCRIPT_FILENAME (default: %[path]) - script_filename: Pattern for SCRIPT_FILENAME (default: %[path])
- index_file: Default index file (default: index.php) - index_file: Default index file (default: index.php)
- path_info: Enable PATH_INFO support (default: true) - path_info: Enable PATH_INFO support (default: true)
@ -608,7 +722,7 @@ Example YAML config:
plugins: plugins:
fastcgi: fastcgi:
enabled: true enabled: true
document_root: /var/www/html document_root: /etc/easyhaproxy/www
index_file: index.php index_file: index.php
path_info: true path_info: true
@ -626,7 +740,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
class FastcgiPlugin(PluginInterface): class FastcgiPlugin(PluginInterface):
@ -634,7 +748,7 @@ class FastcgiPlugin(PluginInterface):
def __init__(self): def __init__(self):
self.enabled = True self.enabled = True
self.document_root = "/var/www/html" self.document_root = "/etc/easyhaproxy/www"
self.script_filename = "%[path]" self.script_filename = "%[path]"
self.index_file = "index.php" self.index_file = "index.php"
self.path_info = True self.path_info = True
@ -720,11 +834,10 @@ class FastcgiPlugin(PluginInterface):
fcgi_app_definition = "\n".join(fcgi_app_lines) fcgi_app_definition = "\n".join(fcgi_app_lines)
# Build metadata - store fcgi_app_definition to be extracted and added to global configs # Build metadata
metadata = { metadata = {
"domain": context.domain, "domain": context.domain,
"fcgi_app_name": fcgi_app_name, "fcgi_app_name": fcgi_app_name,
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
"document_root": self.document_root, "document_root": self.document_root,
"index_file": self.index_file, "index_file": self.index_file,
"path_info": self.path_info, "path_info": self.path_info,
@ -734,7 +847,8 @@ class FastcgiPlugin(PluginInterface):
return PluginResult( return PluginResult(
haproxy_config=backend_config, # use-fcgi-app directive for the backend haproxy_config=backend_config, # use-fcgi-app directive for the backend
modified_easymapping=None, modified_easymapping=None,
metadata=metadata metadata=metadata,
global_configs=[fcgi_app_definition] # For top-level injection (new in v2.0)
) )
``` ```
@ -771,7 +885,7 @@ Example YAML config:
algorithm: RS256 algorithm: RS256
issuer: https://myaccount.auth0.com/ issuer: https://myaccount.auth0.com/
audience: https://api.mywebsite.com audience: https://api.mywebsite.com
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem pubkey_path: /etc/easyhaproxy/jwt_keys/pubkey.pem
paths: paths:
- /api/admin - /api/admin
- /api/sensitive - /api/sensitive
@ -782,7 +896,7 @@ Example Container Label:
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ 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.audience: https://api.example.com
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem 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.paths: /api/admin,/api/sensitive
easyhaproxy.http.plugin.jwt_validator.only_paths: true easyhaproxy.http.plugin.jwt_validator.only_paths: true
""" """
@ -794,8 +908,15 @@ import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import (
from functions import loggerEasyHaproxy InitializationResult,
PluginInterface,
PluginType,
PluginContext,
PluginResult,
ResourceRequest
)
from functions import Functions, logger_easyhaproxy
class JwtValidatorPlugin(PluginInterface): class JwtValidatorPlugin(PluginInterface):
@ -810,6 +931,8 @@ class JwtValidatorPlugin(PluginInterface):
self.pubkey = None # Public key content (alternative to pubkey_path) self.pubkey = None # Public key content (alternative to pubkey_path)
self.paths = [] # List of paths that require JWT validation self.paths = [] # List of paths that require JWT validation
self.only_paths = False # If true, only specified paths are accessible 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 @property
def name(self) -> str: def name(self) -> str:
@ -874,6 +997,19 @@ class JwtValidatorPlugin(PluginInterface):
if "only_paths" in config: if "only_paths" in config:
self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"] 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: def process(self, context: PluginContext) -> PluginResult:
""" """
Generate HAProxy config to validate JWT tokens Generate HAProxy config to validate JWT tokens
@ -893,9 +1029,17 @@ class JwtValidatorPlugin(PluginInterface):
elif self.pubkey: elif self.pubkey:
# Generate path for pubkey based on domain # Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_") domain_safe = context.domain.replace(".", "_").replace(":", "_")
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" 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: else:
loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult() return PluginResult()
# Build HAProxy configuration # Build HAProxy configuration
@ -1021,7 +1165,7 @@ import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginInterface, PluginType, PluginContext, PluginResult
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
class CleanupPlugin(PluginInterface): class CleanupPlugin(PluginInterface):
@ -1057,7 +1201,7 @@ class CleanupPlugin(PluginInterface):
try: try:
self.max_idle_time = int(config["max_idle_time"]) self.max_idle_time = int(config["max_idle_time"])
except ValueError: except ValueError:
loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") logger_easyhaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
if "cleanup_temp_files" in config: if "cleanup_temp_files" in config:
self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"]
@ -1095,15 +1239,15 @@ class CleanupPlugin(PluginInterface):
if file_age > self.max_idle_time: if file_age > self.max_idle_time:
os.remove(filepath) os.remove(filepath)
cleanup_actions.append(f"Removed old temp file: {filepath}") cleanup_actions.append(f"Removed old temp file: {filepath}")
loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") logger_easyhaproxy.debug(f"Cleanup plugin: Removed {filepath}")
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") logger_easyhaproxy.warning(f"Failed to remove temp file {filepath}: {e}")
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
# Log cleanup summary # Log cleanup summary
if cleanup_actions: if cleanup_actions:
loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") logger_easyhaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
return PluginResult( return PluginResult(
haproxy_config="", # No HAProxy config needed for cleanup haproxy_config="", # No HAProxy config needed for cleanup
@ -1117,9 +1261,104 @@ class CleanupPlugin(PluginInterface):
--- ---
## 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_<PLUGIN_NAME>_<CONFIG_KEY>` - 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 ## Best Practices
### 1. Error Handling ### 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. Always handle errors gracefully to avoid breaking HAProxy configuration.
@ -1130,7 +1369,7 @@ def configure(self, config: dict) -> None:
try: try:
self.port = int(config["port"]) self.port = int(config["port"])
except ValueError: except ValueError:
loggerEasyHaproxy.warning(f"Invalid port value: {config['port']}, using default") logger_easyhaproxy.warning(f"Invalid port value: {config['port']}, using default")
self.port = 8080 self.port = 8080
``` ```
@ -1140,7 +1379,7 @@ def configure(self, config: dict) -> None:
self.port = int(config["port"]) # Crashes if not an integer! self.port = int(config["port"]) # Crashes if not an integer!
``` ```
### 2. Configuration Validation ### 4. Configuration Validation
Validate configuration during `configure()` phase, not during `process()`. Validate configuration during `configure()` phase, not during `process()`.
@ -1153,7 +1392,7 @@ def configure(self, config: dict) -> None:
# Validate IPs # Validate IPs
if not self.allowed_ips: if not self.allowed_ips:
loggerEasyHaproxy.warning("IP whitelist plugin: No valid IPs configured") logger_easyhaproxy.warning("IP whitelist plugin: No valid IPs configured")
self.enabled = False self.enabled = False
``` ```
@ -1165,7 +1404,7 @@ def process(self, context: PluginContext) -> PluginResult:
raise ValueError("No IPs configured") raise ValueError("No IPs configured")
``` ```
### 3. Use Metadata for Debugging ### 5. Use Metadata for Debugging
Include useful debugging information in metadata. Include useful debugging information in metadata.
@ -1182,7 +1421,7 @@ return PluginResult(
) )
``` ```
### 4. Handle Boolean Configuration ### 6. Handle Boolean Configuration
Support multiple boolean formats (true/false, 1/0, yes/no). Support multiple boolean formats (true/false, 1/0, yes/no).
@ -1192,7 +1431,7 @@ def configure(self, config: dict) -> None:
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
``` ```
### 5. Support Multiple Configuration Formats ### 7. Support Multiple Configuration Formats
Support both list and comma-separated string formats for lists. Support both list and comma-separated string formats for lists.
@ -1209,7 +1448,7 @@ def configure(self, config: dict) -> None:
self.paths = [] self.paths = []
``` ```
### 6. Use Descriptive Names ### 8. Use Descriptive Names
Use clear, descriptive names for plugins, configuration keys, and ACLs. Use clear, descriptive names for plugins, configuration keys, and ACLs.
@ -1233,7 +1472,7 @@ def name(self) -> str:
acl p1 path_beg /api # What is p1? acl p1 path_beg /api # What is p1?
``` ```
### 7. Document Your Plugin ### 9. Document Your Plugin
Include comprehensive docstrings with configuration examples. Include comprehensive docstrings with configuration examples.
@ -1259,7 +1498,7 @@ Example Container Label:
""" """
``` ```
### 8. Return Empty Result When Disabled ### 10. Return Empty Result When Disabled
Always check `enabled` flag and return empty result early. Always check `enabled` flag and return empty result early.
@ -1271,27 +1510,27 @@ def process(self, context: PluginContext) -> PluginResult:
# Plugin logic here... # Plugin logic here...
``` ```
### 9. Use Logger Appropriately ### 11. Use Logger Appropriately
Use appropriate log levels for different messages. Use appropriate log levels for different messages.
```python ```python
from functions import loggerEasyHaproxy from functions import logger_easyhaproxy
# For debugging # For debugging
loggerEasyHaproxy.debug(f"Processing domain: {context.domain}") logger_easyhaproxy.debug(f"Processing domain: {context.domain}")
# For informational messages # For informational messages
loggerEasyHaproxy.info(f"Loaded plugin configuration: {self.name}") logger_easyhaproxy.info(f"Loaded plugin configuration: {self.name}")
# For warnings (non-fatal issues) # For warnings (non-fatal issues)
loggerEasyHaproxy.warning(f"Invalid configuration value, using default") logger_easyhaproxy.warning(f"Invalid configuration value, using default")
# For errors (fatal issues) # For errors (fatal issues)
loggerEasyHaproxy.error(f"Failed to load required file: {filepath}") logger_easyhaproxy.error(f"Failed to load required file: {filepath}")
``` ```
### 10. Make Domain-Safe Identifiers ### 12. Make Domain-Safe Identifiers
Replace special characters when generating HAProxy identifiers. Replace special characters when generating HAProxy identifiers.
@ -1446,7 +1685,7 @@ services:
- "443:443" - "443:443"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./my_plugin.py:/etc/haproxy/plugins/my_plugin.py - ./my_plugin.py:/etc/easyhaproxy/plugins/my_plugin.py
environment: environment:
- EASYHAPROXY_DISCOVER=docker - EASYHAPROXY_DISCOVER=docker
``` ```
@ -1468,7 +1707,7 @@ Expected output:
**Verify generated configuration:** **Verify generated configuration:**
```bash ```bash
docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin" docker-compose exec haproxy cat /etc/easyhaproxy/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
``` ```
--- ---
@ -1483,13 +1722,13 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
1. **File not in plugins directory** 1. **File not in plugins directory**
```bash ```bash
ls -la /etc/haproxy/plugins/ ls -la /etc/easyhaproxy/plugins/
# Ensure my_plugin.py exists # Ensure my_plugin.py exists
``` ```
2. **Invalid Python syntax** 2. **Invalid Python syntax**
```bash ```bash
python3 -m py_compile /etc/haproxy/plugins/my_plugin.py python3 -m py_compile /etc/easyhaproxy/plugins/my_plugin.py
# Check for syntax errors # Check for syntax errors
``` ```
@ -1575,7 +1814,7 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
1. **Invalid HAProxy syntax in generated config** 1. **Invalid HAProxy syntax in generated config**
```bash ```bash
# Test configuration manually: # Test configuration manually:
haproxy -c -f /etc/haproxy/haproxy.cfg haproxy -c -f /etc/easyhaproxy/haproxy/haproxy.cfg
``` ```
2. **Missing quotes or escaping** 2. **Missing quotes or escaping**
@ -1611,8 +1850,8 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
2. **Add debug statements** 2. **Add debug statements**
```python ```python
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
loggerEasyHaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}") logger_easyhaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}")
loggerEasyHaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}") logger_easyhaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}")
# ... rest of plugin logic # ... rest of plugin logic
``` ```
@ -1630,7 +1869,7 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
# Risky operation # Risky operation
result = self.do_something_risky() result = self.do_something_risky()
except Exception as e: except Exception as e:
loggerEasyHaproxy.error(f"Plugin {self.name} error: {str(e)}") logger_easyhaproxy.error(f"Plugin {self.name} error: {str(e)}")
return PluginResult() # Return empty result on error return PluginResult() # Return empty result on error
``` ```
@ -1668,7 +1907,7 @@ Share your plugin as a single `.py` file:
```bash ```bash
# Users copy the file to their plugins directory: # Users copy the file to their plugins directory:
cp my_plugin.py /etc/haproxy/plugins/ cp my_plugin.py /etc/easyhaproxy/plugins/
``` ```
**Advantages:** **Advantages:**
@ -1696,7 +1935,7 @@ my-easyhaproxy-plugin/
**Installation:** **Installation:**
```bash ```bash
# Users download and install: # Users download and install:
wget https://raw.githubusercontent.com/user/my-plugin/main/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py 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 #### Option 3: Docker Image with Plugin
@ -1710,7 +1949,7 @@ FROM byjg/easy-haproxy:latest
COPY my_plugin.py /app/src/plugins/builtin/ COPY my_plugin.py /app/src/plugins/builtin/
# Optional: Add default configuration # Optional: Add default configuration
COPY plugin_config.yaml /etc/haproxy/static/config.yaml COPY plugin_config.yaml /etc/easyhaproxy/static/config.yaml
``` ```
**Build and distribute:** **Build and distribute:**
@ -1738,7 +1977,7 @@ Brief description of what your plugin does.
### Docker ### Docker
\`\`\`bash \`\`\`bash
wget https://example.com/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py wget https://example.com/my_plugin.py -O /etc/easyhaproxy/plugins/my_plugin.py
\`\`\` \`\`\`
### Kubernetes ### Kubernetes

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 14 sidebar_position: 15
--- ---
# Using Plugins # Using Plugins
@ -71,7 +71,7 @@ metadata:
easyhaproxy.plugin.jwt_validator.algorithm: "RS256" easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/" easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com" easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem" easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/easyhaproxy/jwt_keys/api_pubkey.pem"
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users" easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
easyhaproxy.plugin.jwt_validator.only_paths: "false" easyhaproxy.plugin.jwt_validator.only_paths: "false"
# Configure deny_pages plugin # Configure deny_pages plugin
@ -124,7 +124,7 @@ services:
### 3. Static YAML Configuration ### 3. Static YAML Configuration
Configure plugins in `/etc/haproxy/static/config.yaml`: Configure plugins in `/etc/easyhaproxy/static/config.yaml`:
```yaml ```yaml
plugins: plugins:
@ -200,9 +200,9 @@ services:
easyhaproxy.http.plugins: jwt_validator easyhaproxy.http.plugins: jwt_validator
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth0.myapp.com/ easyhaproxy.http.plugin.jwt_validator.issuer: https://auth0.myapp.com/
easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
volumes: volumes:
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro - ./auth_pubkey.pem:/etc/easyhaproxy/jwt_keys/api_pubkey.pem:ro
``` ```
**Protect only admin/sensitive endpoints:** **Protect only admin/sensitive endpoints:**
@ -213,11 +213,11 @@ services:
labels: labels:
easyhaproxy.http.host: api.example.com easyhaproxy.http.host: api.example.com
easyhaproxy.http.plugins: jwt_validator easyhaproxy.http.plugins: jwt_validator
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem 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.paths: /api/admin,/api/users,/api/billing
easyhaproxy.http.plugin.jwt_validator.only_paths: false easyhaproxy.http.plugin.jwt_validator.only_paths: false
volumes: volumes:
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro - ./auth_pubkey.pem:/etc/easyhaproxy/jwt_keys/api_pubkey.pem:ro
# /api/health, /api/docs, etc. remain publicly accessible # /api/health, /api/docs, etc. remain publicly accessible
``` ```
@ -229,11 +229,11 @@ services:
labels: labels:
easyhaproxy.http.host: api.example.com easyhaproxy.http.host: api.example.com
easyhaproxy.http.plugins: jwt_validator easyhaproxy.http.plugins: jwt_validator
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem 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.paths: /api/v1,/api/v2
easyhaproxy.http.plugin.jwt_validator.only_paths: true easyhaproxy.http.plugin.jwt_validator.only_paths: true
volumes: volumes:
- ./auth_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro - ./auth_pubkey.pem:/etc/easyhaproxy/jwt_keys/api_pubkey.pem:ro
# All paths except /api/v1 and /api/v2 are denied # All paths except /api/v1 and /api/v2 are denied
``` ```
@ -287,7 +287,7 @@ labels:
Keep your system clean with automatic temp file removal: Keep your system clean with automatic temp file removal:
```yaml ```yaml
# /etc/haproxy/static/config.yaml # /etc/easyhaproxy/static/config.yaml
plugins: plugins:
enabled: [cleanup] enabled: [cleanup]
config: config:
@ -346,13 +346,13 @@ EASYHAPROXY_LOG_LEVEL=DEBUG
INFO: Loaded builtin plugin: cloudflare (domain) INFO: Loaded builtin plugin: cloudflare (domain)
INFO: Loaded builtin plugin: cleanup (global) INFO: Loaded builtin plugin: cleanup (global)
DEBUG: Executing domain plugin: cloudflare for domain: example.com DEBUG: Executing domain plugin: cloudflare for domain: example.com
DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/haproxy/cloudflare_ips.lst'} DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/easyhaproxy/cloudflare_ips.lst'}
``` ```
### Plugin Not Loading ### Plugin Not Loading
**Check:** **Check:**
1. Plugin file exists in `/etc/haproxy/plugins/` or builtin directory 1. Plugin file exists in `/etc/easyhaproxy/plugins/` or builtin directory
2. Python syntax is valid 2. Python syntax is valid
3. Plugin class inherits from `PluginInterface` 3. Plugin class inherits from `PluginInterface`
4. Check logs for load errors 4. Check logs for load errors

View file

@ -1,49 +1,38 @@
--- ---
sidebar_position: 9 sidebar_position: 10
--- ---
# Setup custom certificates # Setup custom certificates
You can use your own certificates with EasyHAProxy. You just need to let EasyHAProxy know that certificate. You can use your own certificates with EasyHAProxy instead of (or in addition to) automatic ACME/Certbot certificates.
There are two ways to do that. :::info How SSL Termination Works
SSL termination happens at the **HAProxy level**, NOT in your backend containers.
- Your backend containers should **only** expose HTTP (port 80), not HTTPS
- HAProxy handles all SSL/TLS encryption and decryption
- Backend containers receive plain HTTP traffic from HAProxy
- Do NOT configure SSL in your backend application when using EasyHAProxy
This is the **correct design** - it centralizes SSL management at the proxy layer.
:::
:::info Certificate Types
EasyHAProxy supports two certificate sources:
- **ACME/Certbot automatic certificates** - Issued automatically via Let's Encrypt or other ACME providers (see [ACME documentation](./acme.md))
- **Manual/custom certificates** - Your own certificates loaded via volume mount (recommended) or labels (this page)
Both can be used simultaneously. Per domain, ACME certificates (if `certbot=true` label is set) take precedence over manual certificates.
:::
There are two ways to provide custom certificates:
- [Setup certificate as a label definition in docker container](#setup-certificate-as-a-label-definition-in-docker-container)
- [Map the certificate as a docker volume](#map-the-certificate-as-a-docker-volume) - [Map the certificate as a docker volume](#map-the-certificate-as-a-docker-volume)
- [Setup certificate as a label definition](#setup-certificate-as-a-label-definition-in-docker-container)
## Setup certificate as a label definition in docker container
1. Create a single PEM from the certificate and key.
```bash title="Combine certificate and key"
cat example.com.crt example.com.key > single.pem
cat single.pem
-----BEGIN CERTIFICATE-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC5ZheHqmBnEJP+
U9r1gxYWKLzdqrMrcxtQN6M1hIH9n0peuJeIrybdcV7sMbStMXI=
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIIEojCCA4qgAwIBAgIUegW2BimwuL4RzRZ2WYkHA6U5nkAwDQYJKoZIhvcNAQEL
3j4wz8/I5fdsk090j4s5KA==
-----END PRIVATE KEY-----
```
2. Convert the `single.pem` to BASE64 in a single line:
```bash title="Convert to BASE64"
cat single.pem | base64 -w0
```
3. Define a label in yout container
Add the Base64 string you generated before to the label `easyhaproxy.[definition].sslcert`
## Map the certificate as a docker volume ## Map the certificate as a docker volume
EasyHAProxy stores the certificates inside the container folder `/certs/haproxy`. EasyHAProxy stores the certificates inside the container folder `/etc/easyhaproxy/certs/haproxy`.
1. Run EasyHAProxy with the volume for the certificates: 1. Run EasyHAProxy with the volume for the certificates:
@ -52,7 +41,7 @@ docker volume create certs_haproxy
docker run \ docker run \
/* other parameters */ /* other parameters */
-v certs_haproxy:/certs/haproxy \ -v certs_haproxy:/etc/easyhaproxy/certs/haproxy \
-d byjg/easy-haproxy -d byjg/easy-haproxy
``` ```
@ -77,8 +66,79 @@ MIIEojCCA4qgAwIBAgIUegW2BimwuL4RzRZ2WYkHA6U5nkAwDQYJKoZIhvcNAQEL
3. Copy this certificate to EasyHAProxy volume: 3. Copy this certificate to EasyHAProxy volume:
```bash title="Copy certificate to container" ```bash title="Copy certificate to container"
docker cp single.pem easyhaproxy:/certs/haproxy # IMPORTANT: Filename must match the domain!
docker cp single.pem easyhaproxy:/etc/easyhaproxy/certs/haproxy/example.com.pem
``` ```
:::warning Important Notes
- The filename **must match the domain name**: `example.com.pem` for domain `example.com`
- When using volume-mounted certificates, **do NOT** use the `easyhaproxy.[definition].sslcert` label
- The volume mount method and the label method are **mutually exclusive** per domain
- SSL termination happens at HAProxy - your backend containers should only serve HTTP
:::
4. Configure your backend container (no sslcert label needed):
```yaml
services:
easyhaproxy:
image: byjg/easy-haproxy:6.0.0
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- certs_haproxy:/etc/easyhaproxy/certs/haproxy
ports:
- "80:80"
- "443:443"
myapp:
image: nginx
labels:
easyhaproxy.web.host: example.com
easyhaproxy.web.port: 80 # Frontend port (HAProxy listens here)
easyhaproxy.web.localport: 80 # Backend port (your container)
# NO sslcert label when using volume method!
volumes:
certs_haproxy:
```
## Setup certificate as a label definition in docker container
:::info Alternative Method
This method embeds certificates directly in container labels. Use it when you want certificates in version control or don't want to manage external files. **Volume method is recommended for most use cases.**
:::
1. Create a single PEM from the certificate and key:
```bash title="Combine certificate and key"
cat example.com.crt example.com.key > single.pem
```
2. Convert the `single.pem` to BASE64 in a single line:
```bash title="Convert to BASE64"
cat single.pem | base64 -w0
```
3. Add the Base64 string to your container label:
```yaml
services:
myapp:
image: nginx
labels:
easyhaproxy.web.host: example.com
easyhaproxy.web.port: 80
easyhaproxy.web.localport: 80
easyhaproxy.web.sslcert: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t..." # Base64 certificate
```
:::warning When Using Label Method
- **There is no necessary to** mount the `/etc/easyhaproxy/certs/haproxy` volume for this domain
- Using `sslcert` label means the volume-mounted certificate will be **ignored**
- Certificate is visible in `docker inspect` output (less secure)
- Updating requires container redeployment
:::
---- ----
[Open source ByJG](http://opensource.byjg.com) [Open source ByJG](http://opensource.byjg.com)

View file

@ -29,45 +29,49 @@ ssl_mode: default
logLevel: logLevel:
haproxy: INFO haproxy: INFO
certbot: { certbot:
"email": "acme@example.org" email: "acme@example.org"
}
easymapping: containers:
- port: 80 # HTTP with certbot + redirect to HTTPS
hosts: "host1.com.br:80":
host1.com.br: ip: ["container:5000"]
containers: certbot: true
- container:5000 redirect_ssl: true
certbot: true
redirect_ssl: true
host2.com.br:
containers:
- other:3000
redirect:
www.host1.com.br: http://host1.com.br
- port: 443 # Additional HTTP host
hosts: "host2.com.br:80":
host1.com.br: ip: ["other:3000"]
containers:
- container:80
redirect_ssl: false
ssl: true
- port: 8080 # Redirect www → main domain
hosts: "www.host1.com.br:80":
host3.com.br: ip: ["container:5000"]
containers: redirect_ssl: true
- domain:8181
# HTTPS version
"host1.com.br:443":
ip: ["container:80"]
ssl: true
# Different host on different port
"host3.com.br:8080":
ip: ["domain:8181"]
``` ```
Then map this file to `/etc/haproxy/static/config.yml` in your EasyHAProxy container: :::info New Configuration Format
The `containers` format simplifies static configuration:
- **Flatter structure**: `"hostname:port"` keys instead of nested `easymapping``ports``hosts`
- **Better readability**: Port and localport embedded in keys (`"host:port"` and `"container:localport"`)
- **Plugin support**: Global and per-host plugin configuration
- **Clearer mapping**: Format mirrors internal Docker label structure
:::
Then map this file to `/etc/easyhaproxy/static/config.yml` in your EasyHAProxy container:
```bash title="Run EasyHAProxy with static configuration" ```bash title="Run EasyHAProxy with static configuration"
docker run -d \ docker run -d \
--name easy-haproxy-container \ --name easy-haproxy-container \
-v /my/static/:/etc/haproxy/static/ \ -v /my/static/:/etc/easyhaproxy/static/ \
-e EASYHAPROXY_DISCOVER="static" \ -e EASYHAPROXY_DISCOVER="static" \
# + Environment Variables \ # + Environment Variables \
-p 80:80 \ -p 80:80 \
@ -109,19 +113,23 @@ certbot:
retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations. retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations.
} }
easymapping: containers:
- port: 80 # Listen port # Format: "hostname:port"
"host1.com.br:80":
ip: ["container:5000"] # Endpoints (ip, dns, container, etc) with format "address:localport"
certbot: true # Optional. Request a certbot certificate. Requires certbot.email set.
redirect_ssl: true # Optional. Redirect HTTP to HTTPS for this host.
mode: http # Optional. Default `http`. Can be http or tcp mode: http # Optional. Default `http`. Can be http or tcp
hosts:
host1.com.br: # Hostname # HTTPS version (SSL)
containers: "host1.com.br:443":
- container:5000 # Endpoints of the hostname above (ip, dns, container, etc) ip: ["container:80"]
certbot: true # Optional. it will request a certbot certificate. Needs certbot.email set. ssl: true # Enable SSL for this port
redirect_ssl: true # Optional. It will redirect this site to it SSL.
ssl: true # Optional. Inform this port will listen to SSL, instead of HTTP # Redirect www → main domain (using redirect_ssl with backend)
clone_to_ssl: true # Optional. Default False. You clone these hosts to its equivalent SSL. "www.host1.com.br:80":
redirect: ip: ["container:5000"]
www.host1.com.br: http://host1.com.br redirect_ssl: true
``` ```
:::note SSL Certificates in Static Mode :::note SSL Certificates in Static Mode

View file

@ -33,7 +33,7 @@ And then deploy the EasyHAProxy stack:
```yaml ```yaml
services: services:
haproxy: haproxy:
image: byjg/easy-haproxy:5.0.0 image: byjg/easy-haproxy:6.0.0
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
deploy: deploy:

View file

@ -1,5 +1,5 @@
--- ---
sidebar_position: 13 sidebar_position: 14
--- ---
# Volumes # Volumes
@ -8,15 +8,191 @@ sidebar_position: 13
These volumes allow you to persist certificates, provide custom configurations, and extend EasyHAProxy functionality. These volumes allow you to persist certificates, provide custom configurations, and extend EasyHAProxy functionality.
::: :::
You can map the following volumes: ## Directory Structure
| Volume | Description | :::info Base Path
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------| All EasyHAProxy files are organized under `/etc/easyhaproxy/`. This can be customized using the `EASYHAPROXY_BASE_PATH` environment variable.
| /etc/haproxy/static/ | The folder that will contain the [config.yml](static.md) file for static configuration | :::
| /certs/haproxy/ | The folder that will contain the certificates (`PEM`) for the [SSL](ssl.md) |
| /certs/certbot/ | The folder that will contain the certificates (`PEM`) processed by Certbot (e.g. Let's Encrypt). More info: [acme](acme.md). | ```plaintext title="/etc/easyhaproxy/ Directory Tree"
| /etc/haproxy/conf.d/ | The folder that will contain the [custom configuration](other.md) files. | /etc/easyhaproxy/
| /etc/haproxy/errors-custom/ | The folder that will contain the [custom error](other.md) html files. | ├── static/ # 🔧 Runtime (static mode only)
│ └── config.yml # Static service configuration
├── haproxy/
│ ├── haproxy.cfg # 🔧 Runtime - Generated HAProxy config
│ ├── conf.d/ # 📦 Base image
│ │ ├── README.md
│ │ └── *.cfg # User-provided custom configs
│ └── errors-custom/ # 📦 Base image
│ ├── 400.http # Bad Request
│ ├── 403.http # Forbidden
│ ├── 408.http # Request Timeout
│ ├── 500.http # Internal Server Error
│ ├── 502.http # Bad Gateway
│ ├── 503.http # Service Unavailable
│ └── 504.http # Gateway Timeout
├── certs/
│ ├── live/ # 🔧 Runtime (Certbot)
│ │ └── {domain}/
│ │ ├── cert.pem # Certificate only
│ │ ├── chain.pem # Certificate chain
│ │ ├── fullchain.pem # cert.pem + chain.pem
│ │ ├── privkey.pem # Private key
│ │ └── README
│ ├── archive/ # 🔧 Runtime (Certbot)
│ │ └── {domain}/
│ │ ├── cert1.pem, cert2.pem... # Versioned certificates
│ │ └── privkey1.pem... # Versioned keys
│ ├── work/ # 🔧 Runtime (Certbot working dir)
│ ├── logs/ # 🔧 Runtime (Certbot logs)
│ │ └── letsencrypt.log
│ ├── certbot/ # 📦 Base image
│ │ ├── {domain}.pem # 🔧 Runtime - Merged cert+key
│ │ └── placeholder.pem # 📦 Base image - Placeholder cert
│ └── haproxy/ # 📦 Base image
│ ├── {domain}.pem # User-provided cert+key (PEM format)
│ └── placeholder.pem # 📦 Base image - Placeholder cert
├── plugins/ # Optional - Custom plugins
│ └── *.py # Python plugin files
├── jwt_keys/ # Optional - JWT validation
│ └── *.pem # RSA public keys
├── cloudflare_ips.lst # Optional - Cloudflare plugin
└── www/ # Optional - FastCGI document root
└── index.php
```
:::tip Legend
- **📦 Base image** - Included in the Docker image
- **🔧 Runtime** - Created/generated when EasyHAProxy runs
- **Optional** - Created only when specific features are used
:::
## Common Volume Mappings
The most commonly mapped volumes for persistence and customization:
| Volume | Purpose | Required |
|-------------------------------------------|-------------------------------------------------------------------------------------------------------|----------|
| `/etc/easyhaproxy/static/` | [Static configuration](static.md) - mount your `config.yml` here | Optional |
| `/etc/easyhaproxy/certs/haproxy/` | [SSL certificates](ssl.md) - user-provided certificates in PEM format | Optional |
| `/etc/easyhaproxy/certs/certbot/` | [ACME/Certbot certificates](acme.md) - auto-generated Let's Encrypt certificates | Optional |
| `/etc/easyhaproxy/certs/live/` | Certbot live certificates - persist across container restarts | Optional |
| `/etc/easyhaproxy/haproxy/conf.d/` | [Custom HAProxy config](other.md) - additional `.cfg` files to include | Optional |
| `/etc/easyhaproxy/haproxy/errors-custom/` | [Custom error pages](other.md) - custom HTTP error pages (400, 403, 500, etc.) | Optional |
| `/etc/easyhaproxy/plugins/` | [Custom plugins](plugins.md) - Python plugin files | Optional |
| `/etc/easyhaproxy/jwt_keys/` | [JWT public keys](Plugins/jwt-validator.md) - RSA public keys for JWT validation | Optional |
| `/etc/easyhaproxy/www/` | [FastCGI document root](Plugins/fastcgi.md) - PHP/FastCGI application files | Optional |
## Directory Details
### Configuration Files
#### Static Configuration
```bash
/etc/easyhaproxy/static/config.yml
```
Static service configuration when not using service discovery (Docker/Kubernetes).
:::note
This directory only exists when `EASYHAPROXY_DISCOVER=static` is set.
:::
#### HAProxy Configuration
```bash
/etc/easyhaproxy/haproxy/haproxy.cfg
```
Auto-generated HAProxy configuration file.
:::warning Do Not Edit
This file is automatically generated by EasyHAProxy. Any manual changes will be overwritten.
:::
#### Custom Configuration Snippets
```bash
/etc/easyhaproxy/haproxy/conf.d/*.cfg
```
Place custom HAProxy configuration snippets here. These files are automatically included in the main configuration.
:::tip Example
```bash
# Mount your custom config
docker run -v ./my-custom.cfg:/etc/easyhaproxy/haproxy/conf.d/my-custom.cfg byjg/easy-haproxy
```
:::
### SSL/TLS Certificates
#### User-Provided Certificates
```bash
/etc/easyhaproxy/certs/haproxy/{domain}.pem
```
Place your SSL certificates here in PEM format (certificate + private key combined).
:::info PEM Format
```bash
cat domain.crt domain.key > /etc/easyhaproxy/certs/haproxy/domain.com.pem
```
:::
#### ACME/Let's Encrypt Certificates
```bash
/etc/easyhaproxy/certs/certbot/{domain}.pem # Merged cert+key for HAProxy
/etc/easyhaproxy/certs/live/{domain}/ # Certbot live certificates (symlinks)
/etc/easyhaproxy/certs/archive/{domain}/ # Versioned certificate archive
```
EasyHAProxy automatically merges Certbot certificates from `/etc/easyhaproxy/certs/live/` into `/etc/easyhaproxy/certs/certbot/` for HAProxy consumption.
:::tip Persist Certbot Certificates
```yaml
volumes:
- certbot-certs:/etc/easyhaproxy/certs/live
- certbot-archive:/etc/easyhaproxy/certs/archive
```
:::
### Plugins & Extensions
#### Custom Plugins
```bash
/etc/easyhaproxy/plugins/*.py
```
Add custom Python plugins to extend EasyHAProxy functionality.
See [Plugin Development](plugin-development.md) for details.
#### JWT Public Keys
```bash
/etc/easyhaproxy/jwt_keys/*.pem
```
RSA public keys for [JWT token validation](Plugins/jwt-validator.md).
#### Cloudflare IP Ranges
```bash
/etc/easyhaproxy/cloudflare_ips.lst
```
Cloudflare IP ranges for the [Cloudflare plugin](Plugins/cloudflare.md) to restore real client IPs.
### Error Pages
```bash
/etc/easyhaproxy/haproxy/errors-custom/{code}.http
```
Custom HTTP error pages (400, 403, 408, 500, 502, 503, 504). Default error pages are included in the base image.
:::tip Customize Error Pages
```bash
# Mount your custom 503 error page
docker run -v ./custom-503.http:/etc/easyhaproxy/haproxy/errors-custom/503.http byjg/easy-haproxy
```
:::
---- ----
[Open source ByJG](http://opensource.byjg.com) [Open source ByJG](http://opensource.byjg.com)

View file

@ -1,31 +0,0 @@
# Basic Static Configuration Example
#
# This is a minimal configuration without plugins
# Demonstrates basic HTTP to HTTPS redirect and SSL setup
#
# To use:
# 1. Update the container name and ports to match your setup
# 2. Place SSL certificate at /certs/haproxy/host1.local.pem
# 3. Mount this config: -v ./conf/config-basic.yml:/etc/haproxy/static/config.yml
stats:
username: admin
password: password
port: 1936 # Optional (default 1936)
customerrors: true # Optional (default false)
easymapping:
# HTTP - Redirect to HTTPS
- port: 80
redirect:
host1.local: https://host1.local
www.host1.local: https://host1.local
# HTTPS - Serve application
- port: 443
ssl: true
hosts:
host1.local:
containers:
- container:8080

View file

@ -1,88 +0,0 @@
# Certbot/Let's Encrypt Configuration Example
#
# Demonstrates:
# - Automatic SSL certificate generation with Let's Encrypt
# - HTTP to HTTPS redirect
# - Certificate renewal
#
# Prerequisites:
# 1. Public IP address with ports 80 and 443 accessible
# 2. DNS records pointing to your server:
# example.com -> your-server-ip
# www.example.com -> your-server-ip
#
# 3. Set environment variable:
# EASYHAPROXY_CERTBOT_EMAIL=your-email@example.com
#
# 4. Mount this config:
# -v ./conf/config-certbot.yml:/etc/haproxy/static/config.yml
#
# 5. Persist certificates:
# -v ./certs/certbot:/certs/certbot
#
# How it works:
# - EasyHAProxy requests certificates from Let's Encrypt via HTTP-01 challenge
# - Certificates are stored in /certs/certbot/
# - Certificates auto-renew when needed
#
# Note: Let's Encrypt has rate limits. Use staging environment for testing:
# EASYHAPROXY_CERTBOT_AUTOCONFIG=staging
stats:
username: admin
password: password
port: 1936
customerrors: true
easymapping:
# HTTP Port 80
# Required for ACME HTTP-01 challenge and redirect
- port: 80
hosts:
# Domain with certbot enabled
example.com:
containers:
- webapp:8080
# Enable certbot for this domain
certbot: true
# Redirect HTTP to HTTPS after cert is issued
redirect_ssl: true
# Additional domain with certbot
app.example.com:
containers:
- app:3000
certbot: true
redirect_ssl: true
# Domain without certbot (uses custom certificate)
custom.example.com:
containers:
- custom-app:8080
# No certbot - expects certificate at /certs/haproxy/custom.example.com.pem
# HTTPS Port 443
# Serves HTTPS traffic with auto-generated certificates
- port: 443
ssl: true
hosts:
example.com:
containers:
- webapp:8080
# Certificate path (auto-generated by certbot)
# /certs/certbot/example.com/fullchain.pem
app.example.com:
containers:
- app:3000
# Custom certificate example
custom.example.com:
containers:
- custom-app:8080
# Place your certificate at:
# /certs/haproxy/custom.example.com.pem
# Multiple domains with different backends
# Certbot will request separate certificates for each domain

View file

@ -1,78 +0,0 @@
# Deny Pages Plugin Configuration Example
#
# Demonstrates:
# - Global plugin configuration (applies to all domains)
# - Per-domain plugin override (custom settings per host)
#
# To use:
# 1. Update container names and ports
# 2. Mount this config: -v ./conf/config-deny-pages.yml:/etc/haproxy/static/config.yml
# 3. Test blocked paths:
# curl http://host1.local/admin # Should return 404
# curl http://host2.local/wp-admin # Should return 403 (different config)
stats:
username: admin
password: password
port: 1936
customerrors: true
# Global plugin configuration
# This applies to ALL domains unless overridden
plugins:
enabled:
- deny_pages
config:
deny_pages:
# Global default: block common admin paths with 404
paths:
- /admin
- /.env
- /config
status_code: 404 # Hide existence of these paths
easymapping:
- port: 80
hosts:
# Domain 1: Uses global deny_pages configuration
host1.local:
containers:
- webapp1:8080
# No plugins specified = uses global configuration
# Domain 2: WordPress site with custom blocked paths
host2.local:
containers:
- wordpress:80
# Override global plugin configuration for this domain
plugins:
- deny_pages
plugin_config:
deny_pages:
paths:
- /wp-admin
- /wp-login.php
- /xmlrpc.php
- /wp-config.php
status_code: 403 # Return forbidden instead of 404
# Domain 3: Public site with stricter blocking
host3.local:
containers:
- publicsite:3000
plugins:
- deny_pages
plugin_config:
deny_pages:
paths:
- /admin
- /administrator
- /manager
- /phpmyadmin
- /.git
- /.env
- /config
- /backup
status_code: 404

View file

@ -1,86 +0,0 @@
# JWT Validator Plugin Configuration Example
#
# Demonstrates:
# - JWT token validation for API protection
# - Different JWT configurations per domain
# - Optional issuer/audience validation
#
# Prerequisites:
# 1. Generate RSA key pair:
# openssl genrsa -out jwt_private.pem 2048
# openssl rsa -in jwt_private.pem -pubout -out jwt_pubkey.pem
#
# 2. Mount public keys:
# -v ./jwt_pubkey.pem:/etc/haproxy/jwt_keys/api_pubkey.pem:ro
# -v ./jwt_pubkey2.pem:/etc/haproxy/jwt_keys/admin_pubkey.pem:ro
#
# 3. Mount this config:
# -v ./conf/config-jwt-validator.yml:/etc/haproxy/static/config.yml
#
# 4. Test:
# # Without token - should fail
# curl http://api.local/users
# # Response: Missing Authorization HTTP header
#
# # With valid token - should succeed
# curl -H "Authorization: Bearer eyJhbGc..." http://api.local/users
stats:
username: admin
password: password
port: 1936
customerrors: true
easymapping:
- port: 80
hosts:
# Public API with full JWT validation
api.local:
containers:
- api-server:8080
plugins:
- jwt_validator
plugin_config:
jwt_validator:
algorithm: RS256
issuer: https://auth.example.com/
audience: https://api.example.com
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
# Internal API - validate signature only (no issuer/audience check)
internal-api.local:
containers:
- internal-api:3000
plugins:
- jwt_validator
plugin_config:
jwt_validator:
algorithm: RS256
# No issuer/audience = skip those validations
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
# Admin API - different issuer and key
admin-api.local:
containers:
- admin-api:4000
plugins:
- jwt_validator
- deny_pages # Also block internal paths
plugin_config:
jwt_validator:
algorithm: RS256
issuer: https://admin-auth.example.com/
audience: https://admin.example.com
pubkey_path: /etc/haproxy/jwt_keys/admin_pubkey.pem
deny_pages:
paths:
- /internal
- /debug
status_code: 403
# Public website - no JWT required
website.local:
containers:
- website:8080
# No plugins = public access

View file

@ -15,12 +15,12 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes # This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version. # to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/) # Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 1.0.0 version: 2.0.0
# This is the version number of the application being deployed. This version number should be # This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to # incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using. # follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes. # It is recommended to use it with quotes.
appVersion: "5.0.0" appVersion: "6.0.0"
icon: https://opensource.byjg.com/img/easy_haproxy_logo.png icon: https://opensource.byjg.com/img/easy_haproxy_logo.png

View file

@ -17,7 +17,7 @@ rules:
resources: resources:
# - configmaps # - configmaps
# - endpoints # - endpoints
# - nodes - nodes
- pods - pods
- services - services
- namespaces - namespaces
@ -28,23 +28,21 @@ rules:
- list - list
- watch - watch
- apiGroups: - apiGroups:
- "extensions"
- "networking.k8s.io" - "networking.k8s.io"
resources: resources:
- ingresses - ingresses
# - ingresses/status - ingresses/status
# - ingressclasses - ingressclasses
verbs: verbs:
- get - get
- list - list
- watch - watch
# - apiGroups: - apiGroups:
# - "extensions" - "networking.k8s.io"
# - "networking.k8s.io" resources:
# resources: - ingresses/status
# - ingresses/status verbs:
# verbs: - patch
# - update
- apiGroups: - apiGroups:
- "" - ""
resources: resources:

View file

@ -77,4 +77,18 @@ spec:
{{- if .Values.easyhaproxy.certbot.email }} {{- if .Values.easyhaproxy.certbot.email }}
- name: EASYHAPROXY_CERTBOT_EMAIL - name: EASYHAPROXY_CERTBOT_EMAIL
value: {{ .Values.easyhaproxy.certbot.email }} value: {{ .Values.easyhaproxy.certbot.email }}
{{ end }} {{- end }}
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: EASYHAPROXY_UPDATE_INGRESS_STATUS
value: {{ .Values.ingressStatus.enabled | quote }}
- name: EASYHAPROXY_DEPLOYMENT_MODE
value: {{ .Values.ingressStatus.deploymentMode | quote }}
{{- if .Values.ingressStatus.externalHostname }}
- name: EASYHAPROXY_EXTERNAL_HOSTNAME
value: {{ .Values.ingressStatus.externalHostname | quote }}
{{- end }}
- name: EASYHAPROXY_STATUS_UPDATE_INTERVAL
value: {{ .Values.ingressStatus.updateInterval | quote }}

View file

@ -0,0 +1,14 @@
{{- if .Values.ingressClass.create -}}
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: easyhaproxy
labels:
{{- include "easyhaproxy.labels" . | nindent 4 }}
{{- with .Values.ingressClass.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
controller: byjg.com/easyhaproxy
{{- end }}

View file

@ -31,6 +31,24 @@ serviceAccount:
annotations: {} annotations: {}
name: "" name: ""
# IngressClass configuration
ingressClass:
# Create IngressClass resource
create: true
# Additional annotations for the IngressClass
annotations: {}
# Ingress status update configuration
ingressStatus:
# Enable updating ingress status with load balancer IPs
enabled: true
# Deployment mode: auto (detect), daemonset, nodeport, or clusterip
deploymentMode: auto
# External hostname override (for ClusterIP mode without LoadBalancer)
externalHostname: ""
# How often to update status (seconds)
updateInterval: 30
podAnnotations: {} podAnnotations: {}
podSecurityContext: {} podSecurityContext: {}

94
pyproject.toml Normal file
View file

@ -0,0 +1,94 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "easyhaproxy"
version = "6.0.0"
description = "HAProxy label based routing with service discovery for Docker, Swarm, and Kubernetes"
readme = "README.md"
license = {file = "LICENSE"}
requires-python = ">=3.11"
authors = []
keywords = ["haproxy", "docker", "kubernetes", "service-discovery", "load-balancer"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"Topic :: System :: Systems Administration",
"Topic :: Internet :: Proxy Servers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
"pyyaml>=6.0",
"docker>=7.0.0",
"jinja2>=3.1.0",
"kubernetes>=28.0.0",
"deepdiff>=6.0.0",
"pyopenssl>=24.0.0",
"psutil>=5.9.0",
"requests>=2.31.0",
]
[dependency-groups]
dev = [
"pytest>=9.0.2",
"pytest-cov>=4.1.0",
"ruff>=0.1.0",
"PyJWT>=2.8.0",
"cryptography>=41.0.0",
]
[project.scripts]
easy-haproxy = "easyhaproxy.main:main"
[tool.hatch.build.targets.wheel]
packages = ["src/easyhaproxy", "src/easymapping", "src/functions", "src/processor", "src/plugins", "src/templates"]
[tool.hatch.build.targets.wheel.sources]
"src" = ""
[tool.pytest.ini_options]
addopts = "-v -p no:warnings"
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"kubernetes: marks tests as kubernetes integration tests (deselect with '-m \"not kubernetes\"')",
"ssl: marks tests for SSL/TLS functionality",
"jwt: marks tests for JWT validator plugin",
"loadbalancing: marks tests for load balancing functionality",
"php: marks tests for PHP-FPM FastCGI plugin",
"plugins: marks tests for combined plugin functionality",
"security: marks tests for security features (IP whitelist, etc.)",
"cloudflare: marks tests for Cloudflare IP restoration plugin",
"custom_label: marks tests for custom label prefix functionality",
"static: marks tests for static configuration mode",
"acme: marks tests for certbot/acme",
"proxy_headers: marks tests for proxy headers",
"swarm: marks tests for Docker Swarm mode",
]
[tool.ruff]
line-length = 120
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP"]
ignore = ["E501"]
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*", "*/test_*.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]

View file

@ -6,14 +6,35 @@ usage() {
Usage: Usage:
scripts/bump-version.sh <new-version> scripts/bump-version.sh <new-version>
scripts/bump-version.sh --verify <new-version> scripts/bump-version.sh --verify <new-version>
scripts/bump-version.sh --current
Description: Description:
Updates all version references (images, docs, Helm chart) to <new-version> Updates all version references (images, docs, Helm chart) to <new-version>
and bumps the Helm chart version patch. Use --verify to check the repo is and bumps the Helm chart version patch. Use --verify to check the repo is
already updated for <new-version> (no changes are made). already updated for <new-version> (no changes are made). Use --current to
display the current versions.
EOF EOF
} }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CHART_FILE="$REPO_ROOT/helm/easyhaproxy/Chart.yaml"
# Handle --current flag
if [[ "${1:-}" == "--current" ]]; then
if [[ ! -f "$CHART_FILE" ]]; then
echo "Error: Chart file not found at $CHART_FILE"
exit 1
fi
CURRENT_APP_VERSION=$(grep 'appVersion:' "$CHART_FILE" | head -1 | awk -F'"' '{print $2}')
CURRENT_CHART_VERSION=$(grep '^version:' "$CHART_FILE" | head -1 | awk '{print $2}')
CURRENT_PYPROJECT_VERSION=$(grep '^version = ' "$REPO_ROOT/pyproject.toml" | head -1 | awk -F'"' '{print $2}')
echo "Current versions:"
echo " App Version: $CURRENT_APP_VERSION"
echo " Chart Version: $CURRENT_CHART_VERSION"
echo " pyproject.toml: $CURRENT_PYPROJECT_VERSION"
exit 0
fi
MODE="apply" MODE="apply"
if [[ "${1:-}" == "--verify" ]]; then if [[ "${1:-}" == "--verify" ]]; then
MODE="verify" MODE="verify"
@ -31,10 +52,23 @@ if [[ "$NEW_VERSION" == "latest" ]]; then
exit 0 exit 0
fi fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # Extract base version (strip pre-release suffix for RELEASE.md check)
cd "$REPO_ROOT" # Examples: 5.1.0-beta.1 -> 5.1.0, 5.1.0b1 -> 5.1.0, 5.1.0 -> 5.1.0
BASE_VERSION=$(echo "$NEW_VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/')
CHART_FILE="helm/easyhaproxy/Chart.yaml" # Check if base version exists in RELEASE.md Version History
if ! grep -q "| $BASE_VERSION " "$REPO_ROOT/RELEASE.md"; then
echo "❌ Error: Version $BASE_VERSION not found in RELEASE.md Version History table."
echo ""
echo "Please manually add an entry for version $BASE_VERSION to RELEASE.md before running this script."
echo "Add a row to the Version History table like:"
echo ""
echo "| $BASE_VERSION | YYYY-MM-DD | Major/Minor/Patch | Brief description of changes |"
echo ""
exit 1
fi
cd "$REPO_ROOT"
CURRENT_APP_VERSION=$(grep 'appVersion:' "$CHART_FILE" | head -1 | awk -F'"' '{print $2}') CURRENT_APP_VERSION=$(grep 'appVersion:' "$CHART_FILE" | head -1 | awk -F'"' '{print $2}')
CURRENT_CHART_VERSION_RAW=$(grep '^version:' "$CHART_FILE" | head -1 | awk '{print $2}') CURRENT_CHART_VERSION_RAW=$(grep '^version:' "$CHART_FILE" | head -1 | awk '{print $2}')
# Normalize chart version (strip trailing dots/spaces) # Normalize chart version (strip trailing dots/spaces)
@ -58,12 +92,20 @@ if [[ "$MODE" == "verify" ]]; then
check_contains "version: \"$NEW_VERSION\"" deploy/kubernetes/easyhaproxy-daemonset.yml "K8s daemonset manifest version" check_contains "version: \"$NEW_VERSION\"" deploy/kubernetes/easyhaproxy-daemonset.yml "K8s daemonset manifest version"
check_contains "easy-haproxy:$NEW_VERSION" deploy/kubernetes/easyhaproxy-daemonset.yml "K8s daemonset image tag" check_contains "easy-haproxy:$NEW_VERSION" deploy/kubernetes/easyhaproxy-daemonset.yml "K8s daemonset image tag"
check_contains "easy-haproxy:$NEW_VERSION" deploy/docker/docker-compose.yml "Docker compose image tag" check_contains "easy-haproxy:$NEW_VERSION" deploy/docker/docker-compose.yml "Docker compose image tag"
check_contains "version = \"$NEW_VERSION\"" pyproject.toml "pyproject.toml version"
if grep -R "byjg/easy-haproxy:" examples | grep -v "$NEW_VERSION" >/dev/null; then if grep -R --include='*.yml' "byjg/easy-haproxy:[0-9]" tests_e2e | grep -v "$NEW_VERSION" >/dev/null; then
echo "❌ Examples still reference a different tag. Run bump-version.sh to update." echo "❌ Examples (tests_e2e) still reference a different tag. Run bump-version.sh to update."
STATUS=1 STATUS=1
else else
echo "✅ Examples reference $NEW_VERSION" echo "✅ Examples (tests_e2e) reference $NEW_VERSION"
fi
if grep -R --include='*.md' "easy-haproxy:[0-9]" docs | grep -v "$NEW_VERSION" >/dev/null; then
echo "❌ Docs still reference a different tag. Run bump-version.sh to update."
STATUS=1
else
echo "✅ Docs reference $NEW_VERSION"
fi fi
exit $STATUS exit $STATUS
@ -75,18 +117,22 @@ echo "Updating repository to version $NEW_VERSION (previous appVersion: $CURRENT
sed -i "s#easy-haproxy:[a-zA-Z0-9\\.-]*#easy-haproxy:$NEW_VERSION#g" deploy/docker/docker-compose.yml sed -i "s#easy-haproxy:[a-zA-Z0-9\\.-]*#easy-haproxy:$NEW_VERSION#g" deploy/docker/docker-compose.yml
sed -i "s#version: \"[a-zA-Z0-9\\.-]*\"#version: \"$NEW_VERSION\"#g" deploy/kubernetes/easyhaproxy-*.yml sed -i "s#version: \"[a-zA-Z0-9\\.-]*\"#version: \"$NEW_VERSION\"#g" deploy/kubernetes/easyhaproxy-*.yml
sed -i "s#easy-haproxy:[a-zA-Z0-9\\.-]*#easy-haproxy:$NEW_VERSION#g" deploy/kubernetes/easyhaproxy-*.yml sed -i "s#easy-haproxy:[a-zA-Z0-9\\.-]*#easy-haproxy:$NEW_VERSION#g" deploy/kubernetes/easyhaproxy-*.yml
sed -i "s#easy-haproxy/[a-zA-Z0-9\\.-]*/#easy-haproxy/$NEW_VERSION/#g" docs/kubernetes.md find docs -type f -name '*.md' -exec sed -i "s#easy-haproxy/[0-9][a-zA-Z0-9\\.-]*/#easy-haproxy/$NEW_VERSION/#g" {} \;
sed -i "s#easy-haproxy:[a-zA-Z0-9\\.-]*#easy-haproxy:$NEW_VERSION#g" docs/swarm.md find docs -type f -name '*.md' -exec sed -i "s#easy-haproxy:[0-9][a-zA-Z0-9\\.-]*#easy-haproxy:$NEW_VERSION#g" {} \;
find docs -type f -name '*.md' -exec sed -i "s#raw.githubusercontent.com/byjg/docker-easy-haproxy/[a-zA-Z0-9\\.\\-]*/deploy/kubernetes/easyhaproxy-daemonset.yml#raw.githubusercontent.com/byjg/docker-easy-haproxy/$NEW_VERSION/deploy/kubernetes/easyhaproxy-daemonset.yml#g" {} \;
sed -i "s/^\\*\\*Current Version:\\*\\* \`[^\`]*\`/**Current Version:** \`$NEW_VERSION\`/" RELEASE.md sed -i "s/^\\*\\*Current Version:\\*\\* \`[^\`]*\`/**Current Version:** \`$NEW_VERSION\`/" RELEASE.md
sed -i "s/^\\*\\*App Version:\\*\\* \`[^\`]*\`/**App Version:** \`$NEW_VERSION\`/" RELEASE.md sed -i "s/^\\*\\*App Version:\\*\\* \`[^\`]*\`/**App Version:** \`$NEW_VERSION\`/" RELEASE.md
# Update pyproject.toml
sed -i "s#^version = \"[a-zA-Z0-9\\.-]*\"#version = \"$NEW_VERSION\"#g" pyproject.toml
# Update Helm appVersion # Update Helm appVersion
sed -i "s#appVersion: \"[a-zA-Z0-9\\.-]*\"#appVersion: \"$NEW_VERSION\"#g" "$CHART_FILE" sed -i "s#appVersion: \"[a-zA-Z0-9\\.-]*\"#appVersion: \"$NEW_VERSION\"#g" "$CHART_FILE"
# Update examples # Update examples
find examples -type f -name '*.yml' -exec sed -i "s#\\(byjg/easy-haproxy:\\)[a-zA-Z0-9\\.-]*#\\1$NEW_VERSION#g" {} \; -print find tests_e2e -type f -name '*.yml' -exec sed -i "s#\\(byjg/easy-haproxy:\\)[0-9][a-zA-Z0-9\\.-]*#\\1$NEW_VERSION#g" {} \; -print
# Update raw GitHub URLs in Kubernetes examples # Update raw GitHub URLs in Kubernetes examples
find examples/kubernetes -type f -name '*.yml' -exec sed -i "s#raw.githubusercontent.com/byjg/docker-easy-haproxy/[0-9\\.\\-]*/deploy/kubernetes/easyhaproxy-daemonset.yml#raw.githubusercontent.com/byjg/docker-easy-haproxy/$NEW_VERSION/deploy/kubernetes/easyhaproxy-daemonset.yml#g" {} \; find tests_e2e/kubernetes -type f -name '*.yml' -exec sed -i "s#raw.githubusercontent.com/byjg/docker-easy-haproxy/[a-zA-Z0-9\\.\\-]*/deploy/kubernetes/easyhaproxy-daemonset.yml#raw.githubusercontent.com/byjg/docker-easy-haproxy/$NEW_VERSION/deploy/kubernetes/easyhaproxy-daemonset.yml#g" {} \;
# Bump chart version (patch) # Bump chart version (patch)
SANITIZED_CHART_VERSION="${CURRENT_CHART_VERSION%\.}" SANITIZED_CHART_VERSION="${CURRENT_CHART_VERSION%\.}"
@ -101,9 +147,14 @@ NEXT_CHART_VERSION=$(IFS='.'; echo "${_parts[*]}")
sed -i -E "s/^version: .*/version: $NEXT_CHART_VERSION/" "$CHART_FILE" sed -i -E "s/^version: .*/version: $NEXT_CHART_VERSION/" "$CHART_FILE"
sed -i -E "s/helm\\.sh\\/chart: easyhaproxy-[0-9\\.]+/helm.sh\\/chart: easyhaproxy-$NEXT_CHART_VERSION/" deploy/kubernetes/easyhaproxy-*.yml sed -i -E "s/helm\\.sh\\/chart: easyhaproxy-[0-9\\.]+/helm.sh\\/chart: easyhaproxy-$NEXT_CHART_VERSION/" deploy/kubernetes/easyhaproxy-*.yml
uv sync --dev
echo "Done. Updated appVersion to $NEW_VERSION and chart version to $NEXT_CHART_VERSION." echo "Done. Updated appVersion to $NEW_VERSION and chart version to $NEXT_CHART_VERSION."
echo "Next steps:" echo "Next steps:"
echo " 1) git status (review changes)" echo " 1) git status (review changes)"
echo " 2) git add ." echo " 2) git add ."
echo " 3) git commit -m \"Bump version to $NEW_VERSION (chart $NEXT_CHART_VERSION)\"" echo " 3) git commit -m \"Bump version to $NEW_VERSION (chart $NEXT_CHART_VERSION)\""
echo " 4) Open a PR to master and merge via PR." echo " 4) Open a PR to master and merge via PR."
echo " 5) After merge: git checkout master && git pull"
echo " 6) git tag -a $NEW_VERSION -m \"Release $NEW_VERSION\""
echo " 7) git push origin $NEW_VERSION"

View file

@ -1,20 +0,0 @@
from setuptools import setup, find_packages
with open('README.md') as f:
readme = f.read()
with open('LICENSE') as f:
license = f.read()
setup(
name='easymapping',
version='0.1.0',
description='HAProxy label based routing',
long_description=readme,
author='',
author_email='',
url='',
license=license,
packages=find_packages(exclude=('tests', 'docs'))
)

234
src/easyhaproxy/main.py Normal file
View file

@ -0,0 +1,234 @@
import argparse
import os
import shutil
import sys
from deepdiff import DeepDiff
from functions import (
Certbot,
Consts,
DaemonizeHAProxy,
Functions,
logger_easyhaproxy,
logger_init,
)
from processor import ProcessorInterface
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="easy-haproxy",
description="HAProxy label-based routing with service discovery for Docker, Swarm, and Kubernetes.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
# Core
parser.add_argument("--discover", metavar="MODE",
choices=["static", "docker", "swarm", "kubernetes"],
help="Service discovery mode. Also set by EASYHAPROXY_DISCOVER.")
parser.add_argument("--base-path", metavar="PATH",
help="Base directory for all EasyHAProxy files. Also set by EASYHAPROXY_BASE_PATH.")
parser.add_argument("--label-prefix", metavar="PREFIX",
help="Label/annotation prefix used to discover services. Also set by EASYHAPROXY_LABEL_PREFIX.")
parser.add_argument("--ssl-mode", metavar="MODE",
choices=["strict", "default", "loose"],
help="TLS policy: strict (TLS 1.3 only), default, or loose (all). Also set by EASYHAPROXY_SSL_MODE.")
parser.add_argument("--refresh-conf", metavar="SECONDS", type=int,
help="Interval in seconds to poll for configuration changes. Also set by EASYHAPROXY_REFRESH_CONF.")
parser.add_argument("--customer-errors", metavar="BOOL",
choices=["true", "false"],
help="Enable custom HAProxy HTML error pages. Also set by HAPROXY_CUSTOMERRORS.")
# Logging
log_levels = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"]
parser.add_argument("--log-level", metavar="LEVEL", choices=log_levels,
help="EasyHAProxy log level. Also set by EASYHAPROXY_LOG_LEVEL.")
parser.add_argument("--haproxy-log-level", metavar="LEVEL", choices=log_levels,
help="HAProxy process log level. Also set by HAPROXY_LOG_LEVEL.")
parser.add_argument("--certbot-log-level", metavar="LEVEL", choices=log_levels,
help="Certbot log level. Also set by CERTBOT_LOG_LEVEL.")
# Stats
parser.add_argument("--haproxy-password", metavar="PASSWORD",
help="Enable HAProxy stats dashboard with this password. Also set by HAPROXY_PASSWORD.")
parser.add_argument("--haproxy-username", metavar="USERNAME",
help="HAProxy stats dashboard username. Also set by HAPROXY_USERNAME.")
parser.add_argument("--haproxy-stats-port", metavar="PORT",
help="HAProxy stats dashboard port. Also set by HAPROXY_STATS_PORT.")
parser.add_argument("--haproxy-stats-cors-origin", metavar="ORIGIN",
help="Allowed CORS origin for the stats dashboard. Also set by HAPROXY_STATS_CORS_ORIGIN.")
# ACME / Certbot
parser.add_argument("--certbot-email", metavar="EMAIL",
help="Contact email for ACME/Let's Encrypt. Enables certbot when set. Also set by EASYHAPROXY_CERTBOT_EMAIL.")
parser.add_argument("--certbot-autoconfig", metavar="CA",
choices=["letsencrypt", "letsencrypt_test", "buypass", "buypass_test",
"sslcom_rca", "sslcom_ecc", "google", "google_test", "zerossl"],
help="Shorthand to configure a well-known ACME CA. Also set by EASYHAPROXY_CERTBOT_AUTOCONFIG.")
parser.add_argument("--certbot-server", metavar="URL",
help="Custom ACME server directory URL. Also set by EASYHAPROXY_CERTBOT_SERVER.")
parser.add_argument("--certbot-eab-kid", metavar="KID",
help="External Account Binding key ID (required by some CAs). Also set by EASYHAPROXY_CERTBOT_EAB_KID.")
parser.add_argument("--certbot-eab-hmac-key", metavar="KEY",
help="External Account Binding HMAC key. Also set by EASYHAPROXY_CERTBOT_EAB_HMAC_KEY.")
parser.add_argument("--certbot-retry-count", metavar="N", type=int,
help="Iterations before retrying after a rate limit. Also set by EASYHAPROXY_CERTBOT_RETRY_COUNT.")
parser.add_argument("--certbot-preferred-challenges", metavar="TYPE",
help="ACME challenge type (default: http). Also set by EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES.")
parser.add_argument("--certbot-manual-auth-hook", metavar="SCRIPT",
help="Path to manual auth hook script for certbot. Also set by EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK.")
# Plugins
parser.add_argument("--plugins-enabled", metavar="LIST",
help="Comma-separated list of plugins to enable. Also set by EASYHAPROXY_PLUGINS_ENABLED.")
parser.add_argument("--plugins-abort-on-error", metavar="BOOL",
choices=["true", "false"],
help="Abort startup if a plugin fails to load. Also set by EASYHAPROXY_PLUGINS_ABORT_ON_ERROR.")
# Kubernetes
parser.add_argument("--update-ingress-status", metavar="BOOL",
choices=["true", "false"],
help="Update Kubernetes Ingress status with load-balancer IP. Also set by EASYHAPROXY_UPDATE_INGRESS_STATUS.")
parser.add_argument("--deployment-mode", metavar="MODE",
choices=["auto", "single", "cluster"],
help="Kubernetes deployment mode. Also set by EASYHAPROXY_DEPLOYMENT_MODE.")
parser.add_argument("--external-hostname", metavar="HOSTNAME",
help="External hostname reported in Ingress status. Also set by EASYHAPROXY_EXTERNAL_HOSTNAME.")
parser.add_argument("--ingress-status-update-interval", metavar="SECONDS", type=int,
help="Interval in seconds to update Ingress status. Also set by EASYHAPROXY_STATUS_UPDATE_INTERVAL.")
return parser
def _apply_args_to_env(args: argparse.Namespace) -> None:
"""Write non-None CLI arguments into os.environ so the rest of the code reads them."""
mapping = {
"discover": "EASYHAPROXY_DISCOVER",
"base_path": "EASYHAPROXY_BASE_PATH",
"label_prefix": "EASYHAPROXY_LABEL_PREFIX",
"ssl_mode": "EASYHAPROXY_SSL_MODE",
"refresh_conf": "EASYHAPROXY_REFRESH_CONF",
"customer_errors": "HAPROXY_CUSTOMERRORS",
"log_level": "EASYHAPROXY_LOG_LEVEL",
"haproxy_log_level": "HAPROXY_LOG_LEVEL",
"certbot_log_level": "CERTBOT_LOG_LEVEL",
"haproxy_password": "HAPROXY_PASSWORD",
"haproxy_username": "HAPROXY_USERNAME",
"haproxy_stats_port": "HAPROXY_STATS_PORT",
"haproxy_stats_cors_origin": "HAPROXY_STATS_CORS_ORIGIN",
"certbot_email": "EASYHAPROXY_CERTBOT_EMAIL",
"certbot_autoconfig": "EASYHAPROXY_CERTBOT_AUTOCONFIG",
"certbot_server": "EASYHAPROXY_CERTBOT_SERVER",
"certbot_eab_kid": "EASYHAPROXY_CERTBOT_EAB_KID",
"certbot_eab_hmac_key": "EASYHAPROXY_CERTBOT_EAB_HMAC_KEY",
"certbot_retry_count": "EASYHAPROXY_CERTBOT_RETRY_COUNT",
"certbot_preferred_challenges": "EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES",
"certbot_manual_auth_hook": "EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK",
"plugins_enabled": "EASYHAPROXY_PLUGINS_ENABLED",
"plugins_abort_on_error": "EASYHAPROXY_PLUGINS_ABORT_ON_ERROR",
"update_ingress_status": "EASYHAPROXY_UPDATE_INGRESS_STATUS",
"deployment_mode": "EASYHAPROXY_DEPLOYMENT_MODE",
"external_hostname": "EASYHAPROXY_EXTERNAL_HOSTNAME",
"ingress_status_update_interval": "EASYHAPROXY_STATUS_UPDATE_INTERVAL",
}
for arg_name, env_name in mapping.items():
value = getattr(args, arg_name, None)
if value is not None:
os.environ[env_name] = str(value)
def start():
processor_obj = ProcessorInterface.factory(os.getenv("EASYHAPROXY_DISCOVER"))
if processor_obj is None:
exit(1)
os.makedirs(Consts.certs_certbot, exist_ok=True)
os.makedirs(Consts.certs_haproxy, exist_ok=True)
processor_obj.save_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy)
certbot_certs_found = processor_obj.get_certbot_hosts()
logger_easyhaproxy.info(f'Found hosts: {", ".join(processor_obj.get_hosts())}') # Needs to run after save_config
logger_easyhaproxy.debug(f'Object Found: {processor_obj.get_parsed_object()}')
old_haproxy = None
haproxy = DaemonizeHAProxy()
current_custom_config_files = haproxy.get_custom_config_files()
haproxy.haproxy(DaemonizeHAProxy.HAPROXY_START)
haproxy.sleep()
certbot = Certbot(Consts.certs_certbot)
# Check ACME environment readiness if Certbot is configured
if certbot.email != "":
is_ready, error_msg = Certbot.check_acme_environment_ready(certbot.email, certbot.acme_server)
if not is_ready:
logger_easyhaproxy.warning(f"ACME environment not ready: {error_msg}")
logger_easyhaproxy.warning("Certificate auto-renewal may fail. Verify ACME server configuration.")
else:
logger_easyhaproxy.info("ACME environment validated and ready")
while True:
if old_haproxy is not None:
old_haproxy.kill()
old_haproxy = None
try:
old_parsed = processor_obj.get_parsed_object()
processor_obj.refresh()
if certbot.check_certificates(certbot_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive() or DeepDiff(current_custom_config_files, haproxy.get_custom_config_files()) != {}:
logger_easyhaproxy.info('New configuration found. Reloading...')
logger_easyhaproxy.debug(f'Object Found: {processor_obj.get_parsed_object()}')
processor_obj.save_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy)
certbot_certs_found = processor_obj.get_certbot_hosts()
logger_easyhaproxy.info(f'Found hosts: {", ".join(processor_obj.get_hosts())}') # Needs to after save_config
old_haproxy = haproxy
haproxy = DaemonizeHAProxy()
current_custom_config_files = haproxy.get_custom_config_files()
haproxy.haproxy(DaemonizeHAProxy.HAPROXY_RELOAD)
old_haproxy.terminate()
except Exception as e:
logger_easyhaproxy.fatal(f"Err: {e}")
logger_easyhaproxy.info('Heartbeat')
haproxy.sleep()
def main():
haproxy_bin = shutil.which('haproxy')
if haproxy_bin is None:
print("ERROR: HAProxy is not installed or not in PATH.")
print("Please install HAProxy before running easy-haproxy.")
print(" Debian/Ubuntu: sudo apt install haproxy")
print(" RHEL/Fedora: sudo dnf install haproxy")
print(" macOS: brew install haproxy")
sys.exit(1)
args = _build_parser().parse_args()
_apply_args_to_env(args)
# Reset cached base_path so it re-evaluates after --base-path may have been applied
Consts.reset()
Functions.run_bash(logger_init, f'{haproxy_bin} -v')
logger_init.info(r".........................__.....................................")
logger_init.info(r"..___ ____ ________ __/ /_ ____ _____ _________ _ ____ __")
logger_init.info(r"./ _ \/ __ `/ ___/ / / / __ \/ __ `/ __ \/ ___/ __ \| |/_/ / / /")
logger_init.info(r"/ __/ /_/ (__ ) /_/ / / / / /_/ / /_/ / / / /_/ /> </ /_/ /.")
logger_init.info(r"\___/\__,_/____/\__, /_/ /_/\__,_/ .___/_/ \____/_/|_|\__, /..")
logger_init.info(r".............../____/.........../_/..................../____/...")
logger_init.info(f"Release: {os.getenv('RELEASE_VERSION')}")
logger_init.debug('Environment:')
for name, value in os.environ.items():
if "HAPROXY" in name:
logger_init.debug(f"- {name}: {value}")
start()
if __name__ == '__main__':
main()

View file

@ -1,316 +1,4 @@
import base64 from .config_generator import HaproxyConfigGenerator
import json from .label_handler import DockerLabelHandler
import os
import re
from jinja2 import Environment, FileSystemLoader __all__ = ["DockerLabelHandler", "HaproxyConfigGenerator"]
from functions import loggerEasyHaproxy
class DockerLabelHandler:
def __init__(self, label):
self.__data = None
self.__label_base = label
def get_lookup_label(self):
return self.__label_base
def create(self, key):
if isinstance(key, str):
return "{}.{}".format(self.__label_base, key)
return "{}.{}".format(self.__label_base, ".".join(key))
def get(self, label, default_value=""):
if self.has_label(label):
return self.__data[label]
return default_value
def get_bool(self, label, default_value=False):
if self.has_label(label):
return self.__data[label].lower() in ["true", "1", "yes"]
return default_value
def get_json(self, label, default_value={}):
if self.has_label(label):
value = self.__data[label]
if not value: # Handle empty strings
return default_value
try:
return json.loads(value)
except json.JSONDecodeError as e:
loggerEasyHaproxy.error(
f"Invalid JSON in label '{label}': {value}. Error: {e}. Using default value."
)
return default_value
return default_value
def set_data(self, data):
self.__data = data
def has_label(self, label):
if label in self.__data:
return True
return False
class HaproxyConfigGenerator:
def __init__(self, mapping):
self.mapping = mapping
self.mapping.setdefault("ssl_mode", 'default')
self.mapping.setdefault("certbot", {"email": "", "server": False, "eab_kid": False, "eab_hmac_key": False})
self.mapping["ssl_mode"] = self.mapping["ssl_mode"].lower()
self.label = DockerLabelHandler(mapping['lookup_label'] if 'lookup_label' in mapping else "easyhaproxy")
self.certbot_hosts = []
self.serving_hosts = []
self.certs = {}
# Initialize plugin system
try:
from plugins import PluginManager
self.plugin_manager = PluginManager(
plugins_dir="/etc/haproxy/plugins",
abort_on_error=self.mapping.get("plugins", {}).get("abort_on_error", False)
)
self.plugin_manager.load_plugins()
self.plugin_manager.configure_plugins(self.mapping.get("plugins", {}))
self.global_plugin_configs = []
except Exception as e:
# If plugin system fails to initialize, log but continue
loggerEasyHaproxy.warning(f"Failed to initialize plugin system: {e}")
self.plugin_manager = None
self.global_plugin_configs = []
def generate(self, container_metadata={}):
self.mapping.setdefault("easymapping", [])
if container_metadata != {}:
self.mapping["easymapping"] = self.parse(container_metadata)
# Execute global plugins
if self.plugin_manager:
try:
from plugins import PluginContext
global_context = PluginContext(
parsed_object=container_metadata,
easymapping=self.mapping.get("easymapping", []),
container_env=self.mapping,
domain=None,
port=None,
host_config=None
)
# Get enabled plugins from config
enabled_list = self.mapping.get("plugins", {}).get("enabled", [])
# If enabled list contains only empty string, treat as no plugins enabled
if enabled_list and len(enabled_list) > 0 and enabled_list[0] == "":
enabled_list = []
global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list)
# Extend instead of replace to preserve fcgi-app definitions from domain plugins
global_configs = [r.haproxy_config for r in global_results if r.haproxy_config]
self.global_plugin_configs.extend(global_configs)
except Exception as e:
loggerEasyHaproxy.warning(f"Failed to execute global plugins: {e}")
templates_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'templates')
file_loader = FileSystemLoader(templates_dir)
env = Environment(loader=file_loader)
env.trim_blocks = True
env.lstrip_blocks = True
env.rstrip_blocks = True
template = env.get_template('haproxy.cfg.j2')
return template.render(data=self.mapping, global_plugin_configs=self.global_plugin_configs)
def parse(self, container_metadata):
easymapping = dict()
for container in container_metadata:
d = container_metadata[container]
# Extract the definitions dynamically
definitions = {}
r = re.compile(self.label.get_lookup_label() + r"\.(.*)\..*")
for key in d.keys():
if r.match(key):
definitions[r.search(key).group(1)] = 1
if len(definitions.keys()) == 0:
continue
self.label.set_data(d)
# Parse each definition found.
for definition in sorted(definitions.keys()):
mode = self.label.get(
self.label.create([definition, "mode"]),
"http"
)
# TODO: we can ignore "host" in TCP, but it would break the template
host_label = self.label.create([definition, "host"])
if not self.label.has_label(host_label):
continue
port = self.label.get(
self.label.create([definition, "port"]),
"80"
)
certbot = self.label.get_bool(
self.label.create([definition, "certbot"]),
False
) and self.mapping["certbot"]["email"] != ""
clone_to_ssl = self.label.get_bool(
self.label.create([definition, "clone_to_ssl"])
)
if port not in easymapping:
easymapping[port] = {
"mode": mode,
"ssl-check": "",
"port": port,
"hosts": dict(),
"redirect": dict(),
}
# TODO: this could use `EXPOSE` from `Dockerfile`?
ct_port = self.label.get(
self.label.create([definition, "localport"]),
"80"
)
easymapping[port]["ssl-check"] = self.label.get(
self.label.create([definition, "ssl-check"]),
""
)
# Protocol for backend server communication (e.g., fcgi, h2)
proto = self.label.get(
self.label.create([definition, "proto"]),
""
)
# Unix socket path (alternative to host:port)
socket_path = self.label.get(
self.label.create([definition, "socket"]),
""
)
for hostname in sorted(d[host_label].split(",")):
hostname = hostname.strip()
self.serving_hosts.append("%s:%s" % (hostname, port))
easymapping[port]["hosts"].setdefault(hostname, {})
easymapping[port]["hosts"][hostname].setdefault("containers", [])
easymapping[port]["hosts"][hostname].setdefault("certbot", False)
easymapping[port]["hosts"][hostname].setdefault("proto", proto)
# Determine server address: Unix socket or TCP host:port
if socket_path:
server_address = socket_path
else:
server_address = "{}:{}".format(container, ct_port)
easymapping[port]["hosts"][hostname]["containers"] += [server_address]
easymapping[port]["hosts"][hostname]["certbot"] = certbot
easymapping[port]["hosts"][hostname]["redirect_ssl"] = self.label.get_bool(
self.label.create([definition, "redirect_ssl"])
)
easymapping[port]["hosts"][hostname]["balance"] = self.label.get(
self.label.create([definition, "balance"]),
"roundrobin"
)
easymapping[port]["redirect"] = self.label.get_json(
self.label.create([definition, "redirect"])
)
# Execute domain plugins for this host
if self.plugin_manager:
try:
from plugins import PluginContext
domain_context = PluginContext(
parsed_object=container_metadata,
easymapping=easymapping,
container_env=self.mapping,
domain=hostname,
port=port,
host_config=easymapping[port]["hosts"][hostname]
)
# Check if plugins are enabled for this domain (from labels)
enabled_plugins = []
if self.label.has_label(self.label.create([definition, "plugins"])):
enabled_plugins = self.label.get(
self.label.create([definition, "plugins"]),
""
).split(",")
enabled_plugins = [p.strip() for p in enabled_plugins if p.strip()]
# Extract plugin configurations from labels
# Format: easyhaproxy.http.plugin.PLUGIN_NAME.CONFIG_KEY
plugin_configs = {}
for plugin_name in enabled_plugins:
plugin_configs[plugin_name] = {}
# Look for all labels matching easyhaproxy.{definition}.plugin.{plugin_name}.*
plugin_label_prefix = self.label.create([definition, "plugin", plugin_name])
for label_key in d.keys():
if label_key.startswith(plugin_label_prefix + "."):
# Extract config key (everything after plugin_label_prefix + ".")
config_key = label_key[len(plugin_label_prefix) + 1:]
plugin_configs[plugin_name][config_key] = d[label_key]
# Configure plugins with label-specific configs before execution
for plugin_name, config in plugin_configs.items():
if plugin_name in self.plugin_manager.plugins:
self.plugin_manager.plugins[plugin_name].configure(config)
domain_results = self.plugin_manager.execute_domain_plugins(
domain_context,
enabled_list=enabled_plugins
)
# Store domain plugin configs for this host
easymapping[port]["hosts"][hostname]["plugin_configs"] = [
r.haproxy_config for r in domain_results if r.haproxy_config
]
# Extract fcgi-app definitions from metadata and add to global configs
for result in domain_results:
if result.metadata and "fcgi_app_definition" in result.metadata:
if result.metadata["fcgi_app_definition"] not in self.global_plugin_configs:
self.global_plugin_configs.append(result.metadata["fcgi_app_definition"])
except Exception as e:
loggerEasyHaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}")
easymapping[port]["hosts"][hostname]["plugin_configs"] = []
else:
easymapping[port]["hosts"][hostname]["plugin_configs"] = []
if certbot or clone_to_ssl:
if "443" not in easymapping:
easymapping["443"] = {
"mode": "http",
"ssl-check": "ssl",
"port": "443",
"hosts": dict(),
"redirect": dict(),
}
easymapping["443"]["hosts"][hostname] = dict(easymapping[port]["hosts"][hostname])
easymapping["443"]["hosts"][hostname]["certbot"] = False
easymapping["443"]["hosts"][hostname]["redirect_ssl"] = False
easymapping["443"]["ssl"] = True
self.certbot_hosts.append(
hostname) if certbot and hostname not in self.certbot_hosts else self.certbot_hosts
# handle SSL
ssl_label = self.label.create([definition, "sslcert"])
if self.label.has_label(ssl_label):
filename = "{}.pem".format(d[host_label])
easymapping[port]["ssl"] = True if not clone_to_ssl else False
self.certs[filename] = base64.b64decode(d[ssl_label]).decode('ascii')
if self.label.get_bool(self.label.create([definition, "ssl"])):
easymapping[port]["ssl"] = True if not clone_to_ssl else False
return easymapping.values()

View file

@ -0,0 +1,310 @@
import base64
import os
import re
from jinja2 import Environment, FileSystemLoader
from functions import Functions, logger_easyhaproxy
from .label_handler import DockerLabelHandler
class HaproxyConfigGenerator:
def __init__(self, mapping):
self.mapping = mapping
self.mapping.setdefault("ssl_mode", 'default')
self.mapping.setdefault("certbot", {"email": "", "server": False, "eab_kid": False, "eab_hmac_key": False})
self.mapping["ssl_mode"] = self.mapping["ssl_mode"].lower()
self.label = DockerLabelHandler(mapping['lookup_label'] if 'lookup_label' in mapping else "easyhaproxy")
self.certbot_hosts = []
self.serving_hosts = []
self.certs = {}
self.defaults_plugin_configs = []
# Initialize plugin system
try:
from plugins import PluginManager
self.plugin_manager = PluginManager(
abort_on_error=self.mapping.get("plugins", {}).get("abort_on_error", False)
)
self.plugin_manager.load_plugins()
self.plugin_manager.configure_plugins(self.mapping.get("plugins", {}))
self.plugin_manager.initialize_plugins()
self.global_plugin_configs = []
except Exception as e:
# If plugin system fails to initialize, log but continue
logger_easyhaproxy.warning(f"Failed to initialize plugin system: {e}")
self.plugin_manager = None
self.global_plugin_configs = []
def generate(self, container_metadata={}):
self.mapping.setdefault("easymapping", [])
if container_metadata != {}:
self.mapping["easymapping"] = self.parse(container_metadata)
# Execute global plugins
if self.plugin_manager:
try:
from plugins import PluginContext
global_context = PluginContext(
parsed_object=container_metadata,
easymapping=self.mapping.get("easymapping", []),
container_env=self.mapping,
domain=None,
port=None,
host_config=None
)
# Get enabled plugins from config
enabled_list = self.mapping.get("plugins", {}).get("enabled", [])
# If enabled list contains only empty string, treat as no plugins enabled
if enabled_list and len(enabled_list) > 0 and enabled_list[0] == "":
enabled_list = []
global_results = self.plugin_manager.execute_global_plugins(global_context, enabled_list)
# Extract all plugin configs in a single loop
for result in global_results:
# HAProxy config snippets
if result.haproxy_config:
self.global_plugin_configs.append(result.haproxy_config)
# Global-level configs (e.g., fcgi-app definitions)
for config in result.global_configs:
if config and config not in self.global_plugin_configs:
self.global_plugin_configs.append(config)
# Defaults-level configs (e.g., log-format)
for config in result.defaults_configs:
if config and config not in self.defaults_plugin_configs:
self.defaults_plugin_configs.append(config)
except Exception as e:
logger_easyhaproxy.warning(f"Failed to execute global plugins: {e}")
templates_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), 'templates')
file_loader = FileSystemLoader(templates_dir)
env = Environment(loader=file_loader)
env.trim_blocks = True
env.lstrip_blocks = True
env.rstrip_blocks = True
template = env.get_template('haproxy.cfg.j2')
return template.render(
data=self.mapping,
global_plugin_configs=self.global_plugin_configs,
defaults_plugin_configs=self.defaults_plugin_configs
)
def parse(self, container_metadata):
easymapping = dict()
for container in container_metadata:
d = container_metadata[container]
# Extract the definitions dynamically
definitions = {}
r = re.compile(self.label.get_lookup_label() + r"\.(.*)\..*")
for key in d.keys():
if r.match(key):
definitions[r.search(key).group(1)] = 1
if len(definitions.keys()) == 0:
continue
self.label.set_data(d)
# Parse each definition found.
for definition in sorted(definitions.keys()):
mode = self.label.get(
self.label.create([definition, "mode"]),
"http"
)
# TODO: we can ignore "host" in TCP, but it would break the template
host_label = self.label.create([definition, "host"])
if not self.label.has_label(host_label):
continue
port = self.label.get(
self.label.create([definition, "port"]),
"80"
)
certbot = self.label.get_bool(
self.label.create([definition, "certbot"]),
False
) and self.mapping["certbot"]["email"] != ""
clone_to_ssl = self.label.get_bool(
self.label.create([definition, "clone_to_ssl"])
)
# Check if this is a redirect-only entry (no backend)
redirect_only = self.label.get_bool(
self.label.create([definition, "redirect_only"]),
False
)
if port not in easymapping:
easymapping[port] = {
"mode": mode,
"ssl-check": "",
"port": port,
"hosts": dict(),
"redirect": dict(),
}
if redirect_only:
easymapping[port]["redirect"].update(self.label.get_json(
self.label.create([definition, "redirect"])
))
continue
# TODO: this could use `EXPOSE` from `Dockerfile`?
ct_port = self.label.get(
self.label.create([definition, "localport"]),
"80"
)
easymapping[port]["ssl-check"] = self.label.get(
self.label.create([definition, "ssl-check"]),
""
)
# Protocol for backend server communication (e.g., fcgi, h2)
proto = self.label.get(
self.label.create([definition, "proto"]),
""
)
# Unix socket path (alternative to host:port)
socket_path = self.label.get(
self.label.create([definition, "socket"]),
""
)
for hostname in sorted(d[host_label].split(",")):
hostname = hostname.strip()
self.serving_hosts.append(f"{hostname}:{port}")
easymapping[port]["hosts"].setdefault(hostname, {})
easymapping[port]["hosts"][hostname].setdefault("containers", [])
easymapping[port]["hosts"][hostname].setdefault("certbot", False)
easymapping[port]["hosts"][hostname].setdefault("proto", proto)
# Determine server address: Unix socket or TCP host:port
if socket_path:
server_address = socket_path
else:
server_address = f"{container}:{ct_port}"
easymapping[port]["hosts"][hostname]["containers"] += [server_address]
easymapping[port]["hosts"][hostname]["certbot"] = certbot
easymapping[port]["hosts"][hostname]["redirect_ssl"] = self.label.get_bool(
self.label.create([definition, "redirect_ssl"])
)
easymapping[port]["hosts"][hostname]["balance"] = self.label.get(
self.label.create([definition, "balance"]),
"roundrobin"
)
easymapping[port]["redirect"] = self.label.get_json(
self.label.create([definition, "redirect"])
)
# Execute domain plugins for this host
if self.plugin_manager:
try:
from plugins import PluginContext
domain_context = PluginContext(
parsed_object=container_metadata,
easymapping=easymapping,
container_env=self.mapping,
domain=hostname,
port=port,
host_config=easymapping[port]["hosts"][hostname]
)
# Check if plugins are enabled for this domain (from labels)
enabled_plugins = []
if self.label.has_label(self.label.create([definition, "plugins"])):
enabled_plugins = self.label.get(
self.label.create([definition, "plugins"]),
""
).split(",")
enabled_plugins = [p.strip() for p in enabled_plugins if p.strip()]
# Extract plugin configurations from labels
# Format: easyhaproxy.http.plugin.PLUGIN_NAME.CONFIG_KEY
plugin_configs = {}
for plugin_name in enabled_plugins:
plugin_configs[plugin_name] = {}
# Look for all labels matching easyhaproxy.{definition}.plugin.{plugin_name}.*
plugin_label_prefix = self.label.create([definition, "plugin", plugin_name])
for label_key in d.keys():
if label_key.startswith(plugin_label_prefix + "."):
# Extract config key (everything after plugin_label_prefix + ".")
config_key = label_key[len(plugin_label_prefix) + 1:]
plugin_configs[plugin_name][config_key] = d[label_key]
# Configure plugins with label-specific configs before execution
for plugin_name, config in plugin_configs.items():
if plugin_name in self.plugin_manager.plugins:
self.plugin_manager.plugins[plugin_name].configure(config)
domain_results = self.plugin_manager.execute_domain_plugins(
domain_context,
enabled_list=enabled_plugins
)
# Extract all plugin configs in a single loop
plugin_configs_for_host = []
for result in domain_results:
# HAProxy config snippets for this domain
if result.haproxy_config:
plugin_configs_for_host.append(result.haproxy_config)
# Global-level configs (e.g., fcgi-app definitions)
for config in result.global_configs:
if config and config not in self.global_plugin_configs:
self.global_plugin_configs.append(config)
# Defaults-level configs (e.g., log-format)
for config in result.defaults_configs:
if config and config not in self.defaults_plugin_configs:
self.defaults_plugin_configs.append(config)
# Store domain plugin configs for this host
easymapping[port]["hosts"][hostname]["plugin_configs"] = plugin_configs_for_host
except Exception as e:
logger_easyhaproxy.warning(f"Failed to execute domain plugins for {hostname}: {e}")
easymapping[port]["hosts"][hostname]["plugin_configs"] = []
else:
easymapping[port]["hosts"][hostname]["plugin_configs"] = []
if certbot or clone_to_ssl:
if "443" not in easymapping:
easymapping["443"] = {
"mode": "http",
"ssl-check": "ssl",
"port": "443",
"hosts": dict(),
"redirect": dict(),
}
easymapping["443"]["hosts"][hostname] = dict(easymapping[port]["hosts"][hostname])
easymapping["443"]["hosts"][hostname]["certbot"] = False
easymapping["443"]["hosts"][hostname]["redirect_ssl"] = False
easymapping["443"]["ssl"] = True
self.certbot_hosts.append(
hostname) if certbot and hostname not in self.certbot_hosts else self.certbot_hosts
# handle SSL
ssl_label = self.label.create([definition, "sslcert"])
if self.label.has_label(ssl_label):
filename = f"{d[host_label]}.pem"
easymapping[port]["ssl"] = True if not clone_to_ssl else False
self.certs[filename] = base64.b64decode(d[ssl_label]).decode('ascii')
if self.label.get_bool(self.label.create([definition, "ssl"])):
easymapping[port]["ssl"] = True if not clone_to_ssl else False
return easymapping.values()

View file

@ -0,0 +1,52 @@
import json
from functions import logger_easyhaproxy
class DockerLabelHandler:
def __init__(self, label):
self.__data = None
self.__label_base = label
def get_lookup_label(self):
return self.__label_base
def create(self, key):
if isinstance(key, str):
return f"{self.__label_base}.{key}"
return "{}.{}".format(self.__label_base, ".".join(key))
def get(self, label, default_value=""):
if self.has_label(label):
return self.__data[label]
return default_value
def get_bool(self, label, default_value=False):
if self.has_label(label):
return self.__data[label].lower() in ["true", "1", "yes"]
return default_value
def get_json(self, label, default_value=None):
if default_value is None:
default_value = {}
if self.has_label(label):
value = self.__data[label]
if not value: # Handle empty strings
return default_value
try:
return json.loads(value)
except json.JSONDecodeError as e:
logger_easyhaproxy.error(
f"Invalid JSON in label '{label}': {value}. Error: {e}. Using default value."
)
return default_value
return default_value
def set_data(self, data):
self.__data = data
def has_label(self, label):
if label in self.__data:
return True
return False

View file

@ -1,499 +1,21 @@
import os from .certbot import Certbot
import shlex from .consts import Consts, classproperty
import subprocess from .container_env import ContainerEnv
import sys from .filter import SingleLineNonEmptyFilter
import psutil from .functions import Functions
import time from .haproxy import DaemonizeHAProxy
import logging from .loggers import logger_certbot, logger_easyhaproxy, logger_haproxy, logger_init
from datetime import datetime
from multiprocessing import Process __all__ = [
from typing import Final "Certbot",
import requests "classproperty",
from OpenSSL import crypto "Consts",
"ContainerEnv",
class ContainerEnv: "DaemonizeHAProxy",
@staticmethod "Functions",
def read(): "SingleLineNonEmptyFilter",
env_vars = { "logger_certbot",
"customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False, "logger_easyhaproxy",
"ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default' "logger_haproxy",
} "logger_init",
]
if os.getenv("HAPROXY_PASSWORD"):
env_vars["stats"] = {
"username": os.getenv("HAPROXY_USERNAME") if os.getenv("HAPROXY_USERNAME") else "admin",
"password": os.getenv("HAPROXY_PASSWORD"),
"port": os.getenv("HAPROXY_STATS_PORT") if os.getenv("HAPROXY_STATS_PORT") else "1936",
}
env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv(
"EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy"
env_vars["logLevel"] = {
"easyhaproxy": os.getenv("EASYHAPROXY_LOG_LEVEL") if os.getenv(
"EASYHAPROXY_LOG_LEVEL") else Functions.DEBUG,
"haproxy": os.getenv("HAPROXY_LOG_LEVEL") if os.getenv("HAPROXY_LOG_LEVEL") else Functions.INFO,
"certbot": os.getenv("CERTBOT_LOG_LEVEL") if os.getenv("CERTBOT_LOG_LEVEL") else Functions.DEBUG,
}
env_vars["certbot"] = {
"autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", ""),
"email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""),
"server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False),
"eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""),
"eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""),
"retry_count": int(os.getenv("EASYHAPROXY_CERTBOT_RETRY_COUNT", 60)),
"preferred_challenges": os.getenv("EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES", "http"),
"manual_auth_hook": os.getenv("EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK", False),
}
if env_vars["certbot"]["autoconfig"] != "" and not env_vars["certbot"]["server"] and env_vars["certbot"]["email"] != "":
if env_vars["certbot"]["autoconfig"] == "letsencrypt":
env_vars["certbot"]["server"] = "https://acme-v02.api.letsencrypt.org/directory"
if env_vars["certbot"]["autoconfig"] == "letsencrypt_test":
env_vars["certbot"]["server"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
if env_vars["certbot"]["autoconfig"] == "buypass":
env_vars["certbot"]["server"] = "https://api.buypass.com/acme/directory"
if env_vars["certbot"]["autoconfig"] == "buypass_test":
env_vars["certbot"]["server"] = "https://api.test4.buypass.no/acme/directory"
if env_vars["certbot"]["autoconfig"] == "sslcom_rca":
env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-rsa"
if env_vars["certbot"]["autoconfig"] == "sslcom_ecc":
env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-ecc"
if env_vars["certbot"]["autoconfig"] == "google":
env_vars["certbot"]["server"] = "https://dv.acme-v02.api.pki.goog/directory"
if env_vars["certbot"]["autoconfig"] == "google_test":
env_vars["certbot"]["server"] = "https://dv.acme-v02.test-api.pki.goog/directory"
if env_vars["certbot"]["autoconfig"] == "zerossl":
url = "https://api.zerossl.com/acme/eab-credentials-email"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = "email=" + env_vars["certbot"]["email"]
resp = requests.post(url, headers=headers, data=data).json()
if resp["success"]:
env_vars["certbot"]["server"] = "https://acme.zerossl.com/v2/DV90"
env_vars["certbot"]["eab_kid"] = os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = resp["eab_kid"]
env_vars["certbot"]["eab_hmac_key"] = os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = resp["eab_hmac_key"]
else:
del os.environ["EASYHAPROXY_CERTBOT_EMAIL"]
loggerCertbot.error("Could not obtain ZeroSSL credentials " + resp["error"]["type"])
os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"]
# Plugin configuration
env_vars["plugins"] = {
"abort_on_error": os.getenv("EASYHAPROXY_PLUGINS_ABORT_ON_ERROR", "false").lower() == "true",
"enabled": os.getenv("EASYHAPROXY_PLUGINS_ENABLED", "").split(",") if os.getenv("EASYHAPROXY_PLUGINS_ENABLED") else [],
"config": {} # Individual plugin configs from env vars
}
# Parse individual plugin configs (e.g., EASYHAPROXY_PLUGIN_CLOUDFLARE_*)
for key, value in os.environ.items():
if key.startswith("EASYHAPROXY_PLUGIN_"):
parts = key.split("_", 3) # ['EASYHAPROXY', 'PLUGIN', 'NAME', 'KEY']
if len(parts) >= 4:
plugin_name = parts[2].lower()
config_key = "_".join(parts[3:]).lower()
env_vars["plugins"]["config"].setdefault(plugin_name, {})
env_vars["plugins"]["config"][plugin_name][config_key] = value
return env_vars
class Functions:
HAPROXY_LOG: Final[str] = "HAPROXY"
EASYHAPROXY_LOG: Final[str] = "EASYHAPROXY"
CERTBOT_LOG: Final[str] = "CERTBOT"
INIT_LOG: Final[str] = "INIT"
TRACE: Final[str] = "TRACE"
DEBUG: Final[str] = "DEBUG"
INFO: Final[str] = "INFO"
WARN: Final[str] = "WARN"
ERROR: Final[str] = "ERROR"
FATAL: Final[str] = "FATAL"
@staticmethod
def setup_log(source):
level = os.getenv("%s_LOG_LEVEL" % (source.name.upper()), "").upper()
level_importance = {
Functions.TRACE: logging.DEBUG,
Functions.DEBUG: logging.DEBUG,
Functions.INFO: logging.INFO,
Functions.WARN: logging.WARNING,
Functions.ERROR: logging.ERROR,
Functions.FATAL: logging.FATAL
}
selected_level = level_importance[level] if level in level_importance else logging.INFO
log_source_handler = logging.StreamHandler(sys.stdout)
log_source_formatter = logging.Formatter('%(name)s [%(asctime)s] %(levelname)s - %(message)s')
log_source_handler.setFormatter(log_source_formatter)
log_source_handler.addFilter(SingleLineNonEmptyFilter())
source.setLevel(selected_level)
source.addHandler(log_source_handler)
return selected_level
@staticmethod
def load(filename):
with open(filename, 'r') as content_file:
return content_file.read()
@staticmethod
def save(filename, contents):
with open(filename, 'w') as file:
file.write(contents)
@staticmethod
def run_bash(log_source, command, log_output=True, return_result=True):
if not isinstance(command, (list, tuple)):
command = shlex.split(command)
try:
process = subprocess.Popen(command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
output = []
while True:
line = process.stdout.readline().rstrip()
error_line = process.stderr.readline().rstrip()
output.append(line) if return_result else None
log_source.info(line) if log_output and len(line) > 0 else None
log_source.warning(error_line) if len(error_line) > 0 else None
return_code = process.poll()
if return_code is not None:
lines = []
error_line = process.stderr.readline().rstrip()
for line in process.stdout.readlines():
output.append(line.rstrip()) if return_result else None
lines.append(line.rstrip())
log_source.info(lines) if log_output and len(lines) > 0 else None
log_source.warning(error_line) if len(error_line) > 0 else None
break
return [return_code, output]
except Exception as e:
log_source.error("%s" % e)
return [-99, e]
class Consts:
easyhaproxy_config = "/etc/haproxy/static/config.yml"
haproxy_config = "/etc/haproxy/haproxy.cfg"
custom_config_folder = "/etc/haproxy/conf.d"
certs_certbot = "/certs/certbot"
certs_haproxy = "/certs/haproxy"
class DaemonizeHAProxy:
HAPROXY_START: Final[str] = "start"
HAPROXY_RELOAD: Final[str] = "reload"
def __init__(self, custom_config_folder = None):
self.process = None
self.thread = None
self.sleep_secs = None
self.custom_config_folder = custom_config_folder if custom_config_folder is not None else Consts.custom_config_folder
def haproxy(self, action):
self.__prepare(self.get_haproxy_command(action))
if self.process is None:
return
self.thread = Process(target=self.__start, args=())
self.thread.start()
def get_haproxy_command(self, action, pid_file="/run/haproxy.pid"):
custom_config_files = ""
if len(list(self.get_custom_config_files().keys())) != 0:
custom_config_files = "-f %s" % self.custom_config_folder
if action == DaemonizeHAProxy.HAPROXY_START or not os.path.exists(pid_file):
return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -S /var/run/haproxy.sock" % (custom_config_files, pid_file)
else:
return_code, output = Functions().run_bash(loggerHaproxy, "cat %s" % pid_file, log_output=False)
pid = "".join(output).rstrip()
if psutil.pid_exists(int(pid)):
return "/usr/sbin/haproxy -W -f /etc/haproxy/haproxy.cfg %s -p %s -x /var/run/haproxy.sock -sf %s" % (custom_config_files, pid_file, pid)
else:
os.unlink(pid_file)
loggerHaproxy.warning(
"PID file %s does not exist. Restarting haproxy instead of reload." % pid_file
)
return self.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START, pid_file)
def __prepare(self, command):
if not isinstance(command, (list, tuple)):
command = shlex.split(command)
try:
loggerHaproxy.debug("HAPROXY command: %s" % command)
self.process = subprocess.Popen(command,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=-1,
universal_newlines=True)
except Exception as e:
loggerHaproxy.error("%s" % e)
def __start(self):
try:
with self.process.stdout:
for line in iter(self.process.stdout.readline, b''):
loggerHaproxy.info(line.rstrip())
return_code = self.process.wait()
loggerHaproxy.debug("Return code %s" % return_code)
except Exception as e:
loggerHaproxy.error("%s" % e)
def is_alive(self):
return self.thread.is_alive()
def kill(self):
self.process.kill()
self.thread.kill()
def terminate(self):
self.process.terminate()
self.thread.terminate()
def sleep(self):
if self.sleep_secs is None:
try:
self.sleep_secs = int(os.getenv("EASYHAPROXY_REFRESH_CONF", "10"))
except ValueError:
self.sleep_secs = 10
time.sleep(self.sleep_secs)
def get_custom_config_files(self):
if not os.path.exists(self.custom_config_folder):
return {}
files = {}
for file in os.listdir(self.custom_config_folder):
if file.endswith(".cfg"):
files[os.path.join(self.custom_config_folder, file)] = os.path.getmtime(os.path.join(self.custom_config_folder, file))
return dict(sorted(files.items(), key=lambda t: t[0]))
class Certbot:
def __init__(self, certs):
env = ContainerEnv.read()
self.certs = certs
self.email = env["certbot"]["email"]
self.acme_server = self.set_acme_server(env["certbot"]["server"])
self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"])
self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"])
self.freeze_issue = {}
self.retry_count = env["certbot"]["retry_count"]
self.certbot_preferred_challenges = env["certbot"]["preferred_challenges"]
self.certbot_manual_auth_hook = env["certbot"]["manual_auth_hook"]
@staticmethod
def set_acme_server(acme_server):
if not acme_server:
return ""
if acme_server.lower() == "staging":
return "--staging"
elif acme_server.lower().startswith("http"):
return "--server " + acme_server
else:
return ""
@staticmethod
def set_eab_kid(eab_kid):
if eab_kid != "":
return "--eab-kid \"%s\"" % eab_kid
else:
return ""
@staticmethod
def set_eab_hmac_key(eab_hmac_key):
if eab_hmac_key != "":
return "--eab-hmac-key \"%s\"" % eab_hmac_key
else:
return ""
def check_certificates(self, hosts):
if self.email == "" or len(hosts) == 0:
return False
try:
request_certs = []
renew_certs = []
for host in hosts:
cert_status = self.get_certificate_status(host)
host_arg = '-d %s' % host
if cert_status == "ok" or cert_status == "error":
continue
elif host in self.freeze_issue:
freeze_count = self.freeze_issue.pop(host, 0)
if freeze_count > 0:
loggerCertbot.debug("Waiting freezing period (%d) for %s due previous errors" % (freeze_count, host))
self.freeze_issue[host] = freeze_count-1
elif cert_status == "not_found" or cert_status == "expired":
loggerCertbot.debug("[%s] Request new certificate for %s" % (cert_status, host))
request_certs.append(host_arg)
elif cert_status == "expiring":
loggerCertbot.debug("[%s] Renew certificate for %s" % (cert_status, host))
renew_certs.append(host_arg)
certbot_certonly = ('/usr/bin/certbot certonly {acme_server}'
' --preferred-challenges {challenge}'
' --agree-tos'
' --issuance-timeout 90'
' --no-eff-email'
' --non-interactive'
' --max-log-backups=0'
' {eab_kid} {eab_hmac_key}'
' {certs} --email {email}'.format(eab_kid=self.eab_kid,
eab_hmac_key=self.eab_hmac_key,
certs=' '.join(request_certs),
email=self.email,
challenge=self.certbot_preferred_challenges,
acme_server=self.acme_server)
)
if 'http' in self.certbot_preferred_challenges:
certbot_certonly += (' --http-01-port 2080'
' --standalone'
)
if self.certbot_manual_auth_hook:
certbot_certonly += ' --manual --manual-auth-hook \'{hook}\''.format(hook=self.certbot_manual_auth_hook)
if loggerCertbot.level == logging.DEBUG:
certbot_certonly += ' -v'
loggerCertbot.debug("certbot_certonly: %s" % certbot_certonly)
ret_reload = False
return_code_issue = 0
return_code_renew = 0
if len(request_certs) > 0:
return_code_issue, output = Functions.run_bash(loggerCertbot, certbot_certonly, return_result=False)
ret_reload = True
if len(renew_certs) > 0:
return_code_renew, output = Functions.run_bash(loggerCertbot, "/usr/bin/certbot renew", return_result=False)
ret_reload = True
if ret_reload:
self.find_live_certificates()
if return_code_issue != 0:
self.find_missing_certificates(request_certs)
if return_code_renew != 0:
self.find_missing_certificates(renew_certs)
return ret_reload
except Exception as e:
loggerCertbot.error("%s" % e)
return False
@staticmethod
def merge_certificate(cert, key, filename):
Functions.save(filename, cert + key)
def find_live_certificates(self):
certbot_certs = "/etc/letsencrypt/live/"
if not os.path.exists(certbot_certs):
return
for item in os.listdir(certbot_certs):
path = os.path.join(certbot_certs, item)
if os.path.isdir(path):
cert = Functions.load(os.path.join(path, "cert.pem"))
key = Functions.load(os.path.join(path, "privkey.pem"))
filename = "%s/%s.pem" % (self.certs, item)
self.merge_certificate(cert, key, filename)
def get_certificate_status(self, host):
current_time = time.time()
filename = "%s/%s.pem" % (self.certs, host)
if not os.path.exists(filename):
return "not_found"
try:
with open(filename, 'rb') as file:
certificate_str = file.read()
certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str)
expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], '%Y%m%d%H%M%S').timestamp()
if current_time >= expiration_after:
return "expired"
elif (expiration_after - current_time) // (24 * 3600) <= 15:
return "expiring"
except Exception as e:
loggerCertbot.error("Certificate %s error %s" % (host, e))
return "error"
return "ok"
def find_missing_certificates(self, hosts):
for host in hosts:
if host.startswith("-d "):
host = host[3:]
cert_status = self.get_certificate_status(host)
if cert_status != "ok":
self.freeze_issue[host] = self.retry_count
loggerCertbot.debug("Freeze issuing ssl for %s due failure. The certificate is %s" % (host, cert_status))
class SingleLineNonEmptyFilter(logging.Filter):
"""
Logging filter that ensures messages are single-line and non-empty.
- Collapses newlines into spaces and strips surrounding whitespace.
- Drops the record if the resulting message is empty.
"""
def filter(self, record: logging.LogRecord) -> int:
try:
msg = record.getMessage()
except Exception:
# If formatting fails, drop the record
return 0
# Convert any non-string to string representation
if not isinstance(msg, str):
msg = str(msg)
# Collapse multi-line to single line and trim
sanitized = " ".join(msg.splitlines()).strip()
if sanitized == "":
return 0
# If we changed the message, update the record and clear args
if sanitized != record.getMessage():
record.msg = sanitized
record.args = ()
return 1
# ####################################################################################################################
# Setup Global Log
loggerInit = logging.getLogger(Functions.INIT_LOG)
loggerHaproxy = logging.getLogger(Functions.HAPROXY_LOG)
loggerEasyHaproxy = logging.getLogger(Functions.EASYHAPROXY_LOG)
loggerCertbot = logging.getLogger(Functions.CERTBOT_LOG)
Functions.setup_log(loggerInit)
Functions.setup_log(loggerHaproxy)
Functions.setup_log(loggerEasyHaproxy)
Functions.setup_log(loggerCertbot)

220
src/functions/certbot.py Normal file
View file

@ -0,0 +1,220 @@
import logging
import os
import time
from datetime import datetime
import requests
from OpenSSL import crypto
from .consts import Consts
from .container_env import ContainerEnv
from .functions import Functions
from .loggers import logger_certbot
class Certbot:
def __init__(self, certs):
env = ContainerEnv.read()
self.certs = certs
self.email = env["certbot"]["email"]
self.acme_server = self.set_acme_server(env["certbot"]["server"])
self.eab_kid = self.set_eab_kid(env["certbot"]["eab_kid"])
self.eab_hmac_key = self.set_eab_hmac_key(env["certbot"]["eab_hmac_key"])
self.freeze_issue = {}
self.retry_count = env["certbot"]["retry_count"]
self.certbot_preferred_challenges = env["certbot"]["preferred_challenges"]
self.certbot_manual_auth_hook = env["certbot"]["manual_auth_hook"]
@staticmethod
def set_acme_server(acme_server):
if not acme_server:
return ""
if acme_server.lower() == "staging":
return "--staging"
elif acme_server.lower().startswith("http"):
return "--server " + acme_server
else:
return ""
@staticmethod
def set_eab_kid(eab_kid):
if eab_kid != "":
return f'--eab-kid "{eab_kid}"'
else:
return ""
@staticmethod
def set_eab_hmac_key(eab_hmac_key):
if eab_hmac_key != "":
return f'--eab-hmac-key "{eab_hmac_key}"'
else:
return ""
@staticmethod
def check_acme_environment_ready(email, acme_server):
"""
Check if ACME environment is ready for certificate operations.
Args:
email: EASYHAPROXY_CERTBOT_EMAIL value
acme_server: Processed ACME server string from set_acme_server()
Returns:
tuple: (is_ready: bool, error_message: str)
"""
# Check 1: Email configured
if not email or email == "":
return False, "ACME email not configured (EASYHAPROXY_CERTBOT_EMAIL)"
# Check 2: ACME server configured
if not acme_server or acme_server == "":
return False, "ACME server not configured (EASYHAPROXY_CERTBOT_SERVER)"
# Check 3: ACME server reachability (if URL provided)
if "--server " in acme_server:
server_url = acme_server.replace("--server ", "")
try:
# Use 10s timeout, respect REQUESTS_CA_BUNDLE for Pebble CA
response = requests.get(server_url, timeout=10, verify=os.getenv("REQUESTS_CA_BUNDLE", True))
if response.status_code != 200:
return False, f"ACME server {server_url} returned HTTP {response.status_code}"
# Validate ACME directory structure (RFC 8555)
data = response.json()
if "newAccount" not in data:
return False, f"ACME server {server_url} returned invalid ACME directory"
except requests.exceptions.RequestException as e:
return False, f"ACME server {server_url} not reachable: {str(e)}"
except Exception as e:
return False, f"ACME server validation failed: {str(e)}"
return True, ""
def check_certificates(self, hosts):
if self.email == "" or len(hosts) == 0:
return False
try:
request_certs = []
renew_certs = []
for host in hosts:
cert_status = self.get_certificate_status(host)
host_arg = f'-d {host}'
if cert_status == "ok" or cert_status == "error":
continue
elif host in self.freeze_issue:
freeze_count = self.freeze_issue.pop(host, 0)
if freeze_count > 0:
logger_certbot.debug(f"Waiting freezing period ({freeze_count}) for {host} due previous errors")
self.freeze_issue[host] = freeze_count-1
elif cert_status == "not_found" or cert_status == "expired":
logger_certbot.debug(f"[{cert_status}] Request new certificate for {host}")
request_certs.append(host_arg)
elif cert_status == "expiring":
logger_certbot.debug(f"[{cert_status}] Renew certificate for {host}")
renew_certs.append(host_arg)
certbot_certonly = ('/usr/bin/certbot certonly {acme_server}'
' --config-dir {base_path}/certs'
' --work-dir {base_path}/certs/work'
' --logs-dir {base_path}/certs/logs'
' --preferred-challenges {challenge}'
' --agree-tos'
' --issuance-timeout 90'
' --no-eff-email'
' --non-interactive'
' --max-log-backups=0'
' {eab_kid} {eab_hmac_key}'
' {certs} --email {email}'.format(eab_kid=self.eab_kid,
eab_hmac_key=self.eab_hmac_key,
certs=' '.join(request_certs),
email=self.email,
challenge=self.certbot_preferred_challenges,
acme_server=self.acme_server,
base_path=Consts.base_path)
)
if 'http' in self.certbot_preferred_challenges:
certbot_certonly += (' --http-01-port 2080'
' --standalone'
)
if self.certbot_manual_auth_hook:
certbot_certonly += f' --manual --manual-auth-hook \'{self.certbot_manual_auth_hook}\''
if logger_certbot.level == logging.DEBUG:
certbot_certonly += ' -v'
logger_certbot.debug(f"certbot_certonly: {certbot_certonly}")
ret_reload = False
return_code_issue = 0
return_code_renew = 0
if len(request_certs) > 0:
return_code_issue, output = Functions.run_bash(logger_certbot, certbot_certonly, return_result=False)
ret_reload = True
if len(renew_certs) > 0:
certbot_renew = f"/usr/bin/certbot renew --config-dir {Consts.base_path}/certs --work-dir {Consts.base_path}/certs/work --logs-dir {Consts.base_path}/certs/logs"
return_code_renew, output = Functions.run_bash(logger_certbot, certbot_renew, return_result=False)
ret_reload = True
if ret_reload:
self.find_live_certificates()
if return_code_issue != 0:
self.find_missing_certificates(request_certs)
if return_code_renew != 0:
self.find_missing_certificates(renew_certs)
return ret_reload
except Exception as e:
logger_certbot.error(f"{e}")
return False
@staticmethod
def merge_certificate(cert, key, filename):
Functions.save(filename, cert + key)
def find_live_certificates(self):
certbot_certs = f"{Consts.base_path}/certs/live/"
if not os.path.exists(certbot_certs):
return
for item in os.listdir(certbot_certs):
path = os.path.join(certbot_certs, item)
if os.path.isdir(path):
cert = Functions.load(os.path.join(path, "cert.pem"))
key = Functions.load(os.path.join(path, "privkey.pem"))
filename = f"{self.certs}/{item}.pem"
self.merge_certificate(cert, key, filename)
def get_certificate_status(self, host):
current_time = time.time()
filename = f"{self.certs}/{host}.pem"
if not os.path.exists(filename):
return "not_found"
try:
with open(filename, 'rb') as file:
certificate_str = file.read()
certificate = crypto.load_certificate(crypto.FILETYPE_PEM, certificate_str)
expiration_after = datetime.strptime(certificate.get_notAfter().decode()[:-1], '%Y%m%d%H%M%S').timestamp()
if current_time >= expiration_after:
return "expired"
elif (expiration_after - current_time) // (24 * 3600) <= 15:
return "expiring"
except Exception as e:
logger_certbot.error(f"Certificate {host} error {e}")
return "error"
return "ok"
def find_missing_certificates(self, hosts):
for host in hosts:
if host.startswith("-d "):
host = host[3:]
cert_status = self.get_certificate_status(host)
if cert_status != "ok":
self.freeze_issue[host] = self.retry_count
logger_certbot.debug(f"Freeze issuing ssl for {host} due failure. The certificate is {cert_status}")

59
src/functions/consts.py Normal file
View file

@ -0,0 +1,59 @@
import os
from pathlib import Path
class classproperty:
"""Decorator for class-level properties."""
def __init__(self, func):
self.func = func
def __get__(self, obj, owner):
return self.func(owner)
class Consts:
"""Configuration constants with dynamic path resolution based on EASYHAPROXY_BASE_PATH."""
_base_path = None
@classproperty
def base_path(cls):
"""Base directory for all EasyHAProxy files."""
if cls._base_path is None:
if os.getenv("EASYHAPROXY_BASE_PATH"):
default = os.getenv("EASYHAPROXY_BASE_PATH")
elif os.getuid() == 0:
default = "/etc/easyhaproxy"
else:
default = str(Path.home() / "easyhaproxy")
cls._base_path = default
return cls._base_path
@classmethod
def reset(cls):
"""Reset cached base path to pick up environment variable changes."""
cls._base_path = None
@classproperty
def easyhaproxy_config(cls):
"""Path to static configuration file."""
return f"{cls.base_path}/static/config.yml"
@classproperty
def haproxy_config(cls):
"""Path to generated HAProxy configuration file."""
return f"{cls.base_path}/haproxy/haproxy.cfg"
@classproperty
def custom_config_folder(cls):
"""Path to custom HAProxy config snippets directory."""
return f"{cls.base_path}/haproxy/conf.d"
@classproperty
def certs_certbot(cls):
"""Path to Certbot/ACME certificates directory."""
return f"{cls.base_path}/certs/certbot"
@classproperty
def certs_haproxy(cls):
"""Path to user-provided certificates directory."""
return f"{cls.base_path}/certs/haproxy"

View file

@ -0,0 +1,187 @@
import os
import requests
from .functions import Functions
from .loggers import logger_certbot
class ContainerEnv:
@staticmethod
def read(yaml_config=None):
"""
Read configuration from environment variables, optionally merged with YAML config.
Args:
yaml_config: Optional dict from YAML file (for static mode). YAML values take precedence.
Returns:
Dict with configuration settings
"""
# Convert YAML config to environment variables first (if provided)
if yaml_config:
ContainerEnv._yaml_to_env(yaml_config)
env_vars = {
"customerrors": True if os.getenv("HAPROXY_CUSTOMERRORS") == "true" else False,
"ssl_mode": os.getenv("EASYHAPROXY_SSL_MODE").lower() if os.getenv("EASYHAPROXY_SSL_MODE") else 'default'
}
if os.getenv("HAPROXY_PASSWORD"):
env_vars["stats"] = {
"username": os.getenv("HAPROXY_USERNAME") if os.getenv("HAPROXY_USERNAME") else "admin",
"password": os.getenv("HAPROXY_PASSWORD"),
"port": os.getenv("HAPROXY_STATS_PORT") if os.getenv("HAPROXY_STATS_PORT") else "1936",
"cors_origin": os.getenv("HAPROXY_STATS_CORS_ORIGIN", ""),
}
env_vars["lookup_label"] = os.getenv("EASYHAPROXY_LABEL_PREFIX") if os.getenv(
"EASYHAPROXY_LABEL_PREFIX") else "easyhaproxy"
env_vars["logLevel"] = {
"easyhaproxy": os.getenv("EASYHAPROXY_LOG_LEVEL") if os.getenv(
"EASYHAPROXY_LOG_LEVEL") else Functions.DEBUG,
"haproxy": os.getenv("HAPROXY_LOG_LEVEL") if os.getenv("HAPROXY_LOG_LEVEL") else Functions.INFO,
"certbot": os.getenv("CERTBOT_LOG_LEVEL") if os.getenv("CERTBOT_LOG_LEVEL") else Functions.DEBUG,
}
env_vars["certbot"] = {
"autoconfig": os.getenv("EASYHAPROXY_CERTBOT_AUTOCONFIG", ""),
"email": os.getenv("EASYHAPROXY_CERTBOT_EMAIL", ""),
"server": os.getenv("EASYHAPROXY_CERTBOT_SERVER", False),
"eab_kid": os.getenv("EASYHAPROXY_CERTBOT_EAB_KID", ""),
"eab_hmac_key": os.getenv("EASYHAPROXY_CERTBOT_EAB_HMAC_KEY", ""),
"retry_count": int(os.getenv("EASYHAPROXY_CERTBOT_RETRY_COUNT", 60)),
"preferred_challenges": os.getenv("EASYHAPROXY_CERTBOT_PREFERRED_CHALLENGES", "http"),
"manual_auth_hook": os.getenv("EASYHAPROXY_CERTBOT_MANUAL_AUTH_HOOK", False),
}
if env_vars["certbot"]["autoconfig"] != "" and not env_vars["certbot"]["server"] and env_vars["certbot"]["email"] != "":
if env_vars["certbot"]["autoconfig"] == "letsencrypt":
env_vars["certbot"]["server"] = "https://acme-v02.api.letsencrypt.org/directory"
if env_vars["certbot"]["autoconfig"] == "letsencrypt_test":
env_vars["certbot"]["server"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
if env_vars["certbot"]["autoconfig"] == "buypass":
env_vars["certbot"]["server"] = "https://api.buypass.com/acme/directory"
if env_vars["certbot"]["autoconfig"] == "buypass_test":
env_vars["certbot"]["server"] = "https://api.test4.buypass.no/acme/directory"
if env_vars["certbot"]["autoconfig"] == "sslcom_rca":
env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-rsa"
if env_vars["certbot"]["autoconfig"] == "sslcom_ecc":
env_vars["certbot"]["server"] = "https://acme.ssl.com/sslcom-dv-ecc"
if env_vars["certbot"]["autoconfig"] == "google":
env_vars["certbot"]["server"] = "https://dv.acme-v02.api.pki.goog/directory"
if env_vars["certbot"]["autoconfig"] == "google_test":
env_vars["certbot"]["server"] = "https://dv.acme-v02.test-api.pki.goog/directory"
if env_vars["certbot"]["autoconfig"] == "zerossl":
url = "https://api.zerossl.com/acme/eab-credentials-email"
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = "email=" + env_vars["certbot"]["email"]
resp = requests.post(url, headers=headers, data=data).json()
if resp["success"]:
env_vars["certbot"]["server"] = "https://acme.zerossl.com/v2/DV90"
env_vars["certbot"]["eab_kid"] = os.environ['EASYHAPROXY_CERTBOT_EAB_KID'] = resp["eab_kid"]
env_vars["certbot"]["eab_hmac_key"] = os.environ['EASYHAPROXY_CERTBOT_EAB_HMAC_KEY'] = resp["eab_hmac_key"]
else:
del os.environ["EASYHAPROXY_CERTBOT_EMAIL"]
logger_certbot.error("Could not obtain ZeroSSL credentials " + resp["error"]["type"])
os.environ['EASYHAPROXY_CERTBOT_SERVER'] = env_vars["certbot"]["server"]
# Plugin configuration
env_vars["plugins"] = {
"abort_on_error": os.getenv("EASYHAPROXY_PLUGINS_ABORT_ON_ERROR", "false").lower() == "true",
"enabled": os.getenv("EASYHAPROXY_PLUGINS_ENABLED", "").split(",") if os.getenv("EASYHAPROXY_PLUGINS_ENABLED") else [],
"config": {} # Individual plugin configs from env vars
}
# Parse individual plugin configs (e.g., EASYHAPROXY_PLUGIN_CLOUDFLARE_*)
for key, value in os.environ.items():
if key.startswith("EASYHAPROXY_PLUGIN_"):
parts = key.split("_", 3) # ['EASYHAPROXY', 'PLUGIN', 'NAME', 'KEY']
if len(parts) >= 4:
plugin_name = parts[2].lower()
config_key = "_".join(parts[3:]).lower()
env_vars["plugins"]["config"].setdefault(plugin_name, {})
env_vars["plugins"]["config"][plugin_name][config_key] = value
# Ingress status update configuration
env_vars["update_ingress_status"] = os.getenv("EASYHAPROXY_UPDATE_INGRESS_STATUS", "true").lower() == "true"
env_vars["deployment_mode"] = os.getenv("EASYHAPROXY_DEPLOYMENT_MODE", "auto")
env_vars["external_hostname"] = os.getenv("EASYHAPROXY_EXTERNAL_HOSTNAME", "")
env_vars["ingress_status_update_interval"] = int(os.getenv("EASYHAPROXY_STATUS_UPDATE_INTERVAL", "30"))
return env_vars
@staticmethod
def _yaml_to_env(yaml_config):
"""Convert YAML configuration to environment variables"""
# Convert customerrors
if 'customerrors' in yaml_config:
os.environ['HAPROXY_CUSTOMERRORS'] = 'true' if yaml_config['customerrors'] else 'false'
# Convert ssl_mode
if 'ssl_mode' in yaml_config:
os.environ['EASYHAPROXY_SSL_MODE'] = str(yaml_config['ssl_mode'])
# Convert stats
if 'stats' in yaml_config:
stats = yaml_config['stats']
if 'username' in stats:
os.environ['HAPROXY_USERNAME'] = str(stats['username'])
if 'password' in stats:
os.environ['HAPROXY_PASSWORD'] = str(stats['password'])
if 'port' in stats:
os.environ['HAPROXY_STATS_PORT'] = str(stats['port'])
if 'cors_origin' in stats:
os.environ['HAPROXY_STATS_CORS_ORIGIN'] = str(stats['cors_origin'])
# Convert logLevel
if 'logLevel' in yaml_config:
log_level = yaml_config['logLevel']
for source, level in log_level.items():
os.environ[source.upper() + '_LOG_LEVEL'] = str(level)
# Convert certbot
if 'certbot' in yaml_config:
certbot = yaml_config['certbot']
for config, value in certbot.items():
os.environ['EASYHAPROXY_CERTBOT_' + config.upper()] = str(value)
# Convert plugins
if 'plugins' in yaml_config:
plugins = yaml_config['plugins']
# Convert enabled list
if 'enabled' in plugins:
enabled_list = plugins['enabled'] if isinstance(plugins['enabled'], list) else [plugins['enabled']]
os.environ['EASYHAPROXY_PLUGINS_ENABLED'] = ','.join(enabled_list)
# Convert abort_on_error
if 'abort_on_error' in plugins:
os.environ['EASYHAPROXY_PLUGINS_ABORT_ON_ERROR'] = 'true' if plugins['abort_on_error'] else 'false'
# Convert plugin configs
if 'config' in plugins:
for plugin_name, plugin_config in plugins['config'].items():
for config_key, config_value in plugin_config.items():
# Convert to env var format: EASYHAPROXY_PLUGIN_<NAME>_<KEY>
env_key = f"EASYHAPROXY_PLUGIN_{plugin_name.upper()}_{config_key.upper()}"
# Convert list values to comma-separated strings
if isinstance(config_value, list):
env_value = ','.join(str(v) for v in config_value)
else:
env_value = str(config_value)
os.environ[env_key] = env_value

31
src/functions/filter.py Normal file
View file

@ -0,0 +1,31 @@
import logging
class SingleLineNonEmptyFilter(logging.Filter):
"""
Logging filter that ensures messages are single-line and non-empty.
- Collapses newlines into spaces and strips surrounding whitespace.
- Drops the record if the resulting message is empty.
"""
def filter(self, record: logging.LogRecord) -> int:
try:
msg = record.getMessage()
except Exception:
# If formatting fails, drop the record
return 0
# Convert any non-string to string representation
if not isinstance(msg, str):
msg = str(msg)
# Collapse multi-line to single line and trim
sanitized = " ".join(msg.splitlines()).strip()
if sanitized == "":
return 0
# If we changed the message, update the record and clear args
if sanitized != record.getMessage():
record.msg = sanitized
record.args = ()
return 1

View file

@ -0,0 +1,88 @@
import logging
import os
import shlex
import subprocess
import sys
from typing import Final
from .filter import SingleLineNonEmptyFilter
class Functions:
HAPROXY_LOG: Final[str] = "HAPROXY"
EASYHAPROXY_LOG: Final[str] = "EASYHAPROXY"
CERTBOT_LOG: Final[str] = "CERTBOT"
INIT_LOG: Final[str] = "INIT"
TRACE: Final[str] = "TRACE"
DEBUG: Final[str] = "DEBUG"
INFO: Final[str] = "INFO"
WARN: Final[str] = "WARN"
ERROR: Final[str] = "ERROR"
FATAL: Final[str] = "FATAL"
@staticmethod
def setup_log(source):
level = os.getenv(f"{source.name.upper()}_LOG_LEVEL", "").upper()
level_importance = {
Functions.TRACE: logging.DEBUG,
Functions.DEBUG: logging.DEBUG,
Functions.INFO: logging.INFO,
Functions.WARN: logging.WARNING,
Functions.ERROR: logging.ERROR,
Functions.FATAL: logging.FATAL
}
selected_level = level_importance[level] if level in level_importance else logging.INFO
log_source_handler = logging.StreamHandler(sys.stdout)
log_source_formatter = logging.Formatter('%(name)s [%(asctime)s] %(levelname)s - %(message)s')
log_source_handler.setFormatter(log_source_formatter)
log_source_handler.addFilter(SingleLineNonEmptyFilter())
source.setLevel(selected_level)
source.addHandler(log_source_handler)
return selected_level
@staticmethod
def load(filename):
with open(filename) as content_file:
return content_file.read()
@staticmethod
def save(filename, contents):
with open(filename, 'w') as file:
file.write(contents)
@staticmethod
def run_bash(log_source, command, log_output=True, return_result=True):
if not isinstance(command, (list, tuple)):
command = shlex.split(command)
try:
process = subprocess.Popen(command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
output = []
while True:
line = process.stdout.readline().rstrip()
error_line = process.stderr.readline().rstrip()
output.append(line) if return_result else None
log_source.info(line) if log_output and len(line) > 0 else None
log_source.warning(error_line) if len(error_line) > 0 else None
return_code = process.poll()
if return_code is not None:
lines = []
error_line = process.stderr.readline().rstrip()
for line in process.stdout.readlines():
output.append(line.rstrip()) if return_result else None
lines.append(line.rstrip())
log_source.info(lines) if log_output and len(lines) > 0 else None
log_source.warning(error_line) if len(error_line) > 0 else None
break
return [return_code, output]
except Exception as e:
log_source.error(f"{e}")
return [-99, e]

152
src/functions/haproxy.py Normal file
View file

@ -0,0 +1,152 @@
import os
import shlex
import shutil
import subprocess
import sys
import time
from multiprocessing import Process
from typing import Final
import psutil
from .consts import Consts
from .functions import Functions
from .loggers import logger_haproxy
class DaemonizeHAProxy:
HAPROXY_START: Final[str] = "start"
HAPROXY_RELOAD: Final[str] = "reload"
def __init__(self, custom_config_folder=None):
self.process = None
self.thread = None
self.sleep_secs = None
self.custom_config_folder = custom_config_folder if custom_config_folder is not None else Consts.custom_config_folder
def haproxy(self, action):
error = self.__prepare(self.get_haproxy_command(action), action)
if error or self.process is None:
logger_haproxy.fatal(f"Failed to start HAProxy ({action}). Exiting.")
sys.exit(1)
self.thread = Process(target=self.__start, args=())
self.thread.start()
@staticmethod
def get_haproxy_bin() -> str:
return shutil.which('haproxy') or '/usr/sbin/haproxy'
def get_haproxy_command(self, action, pid_file="/run/haproxy.pid"):
haproxy_bin = DaemonizeHAProxy.get_haproxy_bin()
custom_config_files = ""
if len(list(self.get_custom_config_files().keys())) != 0:
custom_config_files = f"-f {self.custom_config_folder}"
if action == DaemonizeHAProxy.HAPROXY_START or not os.path.exists(pid_file):
return f"{haproxy_bin} -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -S /var/run/haproxy.sock"
else:
return_code, output = Functions().run_bash(logger_haproxy, f"cat {pid_file}", log_output=False)
pid = "".join(output).rstrip()
if psutil.pid_exists(int(pid)):
return f"{haproxy_bin} -W -f {Consts.haproxy_config} {custom_config_files} -p {pid_file} -x /var/run/haproxy.sock -sf {pid}"
else:
os.unlink(pid_file)
logger_haproxy.warning(
f"PID file {pid_file} does not exist. Restarting haproxy instead of reload."
)
return self.get_haproxy_command(DaemonizeHAProxy.HAPROXY_START, pid_file)
def __validate_config(self):
"""Validate HAProxy configuration before starting."""
validation_cmd = ["haproxy", "-c", "-f", Consts.haproxy_config]
# Add custom config files if they exist
for config_file in self.get_custom_config_files().keys():
validation_cmd.extend(["-f", config_file])
try:
result = subprocess.run(
validation_cmd,
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return result.stderr if result.stderr else result.stdout
return None
except subprocess.TimeoutExpired:
return "HAProxy configuration validation timed out"
except Exception as e:
return f"Error validating configuration: {e}"
def __prepare(self, command, action=None):
if not isinstance(command, (list, tuple)):
command = shlex.split(command)
# Validate HAProxy config before starting (but not on reload - HAProxy validates itself during reload)
if action == DaemonizeHAProxy.HAPROXY_START:
validation_error = self.__validate_config()
if validation_error:
logger_haproxy.fatal(f"HAProxy configuration validation failed:\n{validation_error}")
return validation_error
try:
logger_haproxy.debug(f"HAPROXY command: {command}")
self.process = subprocess.Popen(command,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=-1,
universal_newlines=True)
return None
except Exception as e:
error_msg = f"Failed to start HAProxy process: {e}"
logger_haproxy.error(error_msg)
return error_msg
def __start(self):
try:
with self.process.stdout:
for line in iter(self.process.stdout.readline, b''):
logger_haproxy.info(line.rstrip())
return_code = self.process.wait()
logger_haproxy.debug(f"Return code {return_code}")
except Exception as e:
logger_haproxy.error(f"{e}")
def is_alive(self):
return self.thread.is_alive()
def kill(self):
self.process.kill()
self.thread.kill()
def terminate(self):
self.process.terminate()
self.thread.terminate()
def sleep(self):
if self.sleep_secs is None:
try:
self.sleep_secs = int(os.getenv("EASYHAPROXY_REFRESH_CONF", "10"))
except ValueError:
self.sleep_secs = 10
time.sleep(self.sleep_secs)
def get_custom_config_files(self):
if not os.path.exists(self.custom_config_folder):
return {}
files = {}
for file in os.listdir(self.custom_config_folder):
if file.endswith(".cfg"):
files[os.path.join(self.custom_config_folder, file)] = os.path.getmtime(os.path.join(self.custom_config_folder, file))
return dict(sorted(files.items(), key=lambda t: t[0]))

13
src/functions/loggers.py Normal file
View file

@ -0,0 +1,13 @@
import logging
from .functions import Functions
logger_init = logging.getLogger(Functions.INIT_LOG)
logger_haproxy = logging.getLogger(Functions.HAPROXY_LOG)
logger_easyhaproxy = logging.getLogger(Functions.EASYHAPROXY_LOG)
logger_certbot = logging.getLogger(Functions.CERTBOT_LOG)
Functions.setup_log(logger_init)
Functions.setup_log(logger_haproxy)
Functions.setup_log(logger_easyhaproxy)
Functions.setup_log(logger_certbot)

View file

@ -1,78 +0,0 @@
import os
from deepdiff import DeepDiff
from functions import Functions, DaemonizeHAProxy, Certbot, Consts, loggerInit, loggerEasyHaproxy, loggerHaproxy, \
loggerCertbot
from processor import ProcessorInterface
def start():
processor_obj = ProcessorInterface.factory(os.getenv("EASYHAPROXY_DISCOVER"))
if processor_obj is None:
exit(1)
os.makedirs(Consts.certs_certbot, exist_ok=True)
os.makedirs(Consts.certs_haproxy, exist_ok=True)
processor_obj.save_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy)
certbot_certs_found = processor_obj.get_certbot_hosts()
loggerEasyHaproxy.info('Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to run after save_config
loggerEasyHaproxy.debug('Object Found: %s' % (processor_obj.get_parsed_object()))
old_haproxy = None
haproxy = DaemonizeHAProxy()
current_custom_config_files = haproxy.get_custom_config_files()
haproxy.haproxy(DaemonizeHAProxy.HAPROXY_START)
haproxy.sleep()
certbot = Certbot(Consts.certs_certbot)
while True:
if old_haproxy is not None:
old_haproxy.kill()
old_haproxy = None
try:
old_parsed = processor_obj.get_parsed_object()
processor_obj.refresh()
if certbot.check_certificates(certbot_certs_found) or DeepDiff(old_parsed, processor_obj.get_parsed_object()) != {} or not haproxy.is_alive() or DeepDiff(current_custom_config_files, haproxy.get_custom_config_files()) != {}:
loggerEasyHaproxy.info('New configuration found. Reloading...')
loggerEasyHaproxy.debug('Object Found: %s' % (processor_obj.get_parsed_object()))
processor_obj.save_config(Consts.haproxy_config)
processor_obj.save_certs(Consts.certs_haproxy)
certbot_certs_found = processor_obj.get_certbot_hosts()
loggerEasyHaproxy.info('Found hosts: %s' % ", ".join(processor_obj.get_hosts())) # Needs to after save_config
old_haproxy = haproxy
haproxy = DaemonizeHAProxy()
current_custom_config_files = haproxy.get_custom_config_files()
haproxy.haproxy(DaemonizeHAProxy.HAPROXY_RELOAD)
old_haproxy.terminate()
except Exception as e:
loggerEasyHaproxy.fatal("Err: %s" % e)
loggerEasyHaproxy.info('Heartbeat')
haproxy.sleep()
def main():
Functions.run_bash(loggerInit, '/usr/sbin/haproxy -v')
loggerInit.info(" _ ")
loggerInit.info(" ___ __ _ ____ _ ___| |_ __ _ _ __ _ _ _____ ___ _ ")
loggerInit.info("/ -_) _` (_-< || |___| ' \\/ _` | '_ \\ '_/ _ \\ \\ / || |")
loggerInit.info("\\___\\__,_/__/\\_, | |_||_\\__,_| .__/_| \\___/_\\_\\_, |")
loggerInit.info(" |__/ |_| |__/ ")
loggerInit.info("Release: %s" % (os.getenv("RELEASE_VERSION")))
loggerInit.debug('Environment:')
for name, value in os.environ.items():
if "HAPROXY" in name:
loggerInit.debug("- {0}: {1}".format(name, value))
start()
if __name__ == '__main__':
main()

View file

@ -1,250 +1,13 @@
import os from .interface import PluginInterface
import importlib.util from .manager import PluginManager
import sys from .types import InitializationResult, PluginContext, PluginResult, PluginType, ResourceRequest
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Dict, Any, List
from functions import loggerEasyHaproxy
__all__ = [
class PluginType(Enum): "InitializationResult",
"""Plugin execution types""" "PluginContext",
GLOBAL = "global" # Execute once per discovery cycle "PluginInterface",
DOMAIN = "domain" # Execute per domain/host "PluginManager",
"PluginResult",
"PluginType",
@dataclass "ResourceRequest",
class PluginContext: ]
"""Container for all plugin execution data"""
parsed_object: dict # {IP: labels} from discovery
easymapping: list # Current HAProxy mapping structure
container_env: dict # Environment configuration
domain: Optional[str] = None # Domain name (for DOMAIN plugins)
port: Optional[str] = None # Port (for DOMAIN plugins)
host_config: Optional[dict] = None # Domain-specific config
@dataclass
class PluginResult:
"""Plugin execution result"""
haproxy_config: str = "" # HAProxy config snippet to inject
modified_easymapping: Optional[list] = None # Modified easymapping structure
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging
class PluginInterface(ABC):
"""Base class all plugins must inherit"""
@property
@abstractmethod
def name(self) -> str:
"""Return the unique plugin name"""
pass
@property
@abstractmethod
def plugin_type(self) -> PluginType:
"""Return the plugin type (GLOBAL or DOMAIN)"""
pass
@abstractmethod
def configure(self, config: dict) -> None:
"""
Configure the plugin with settings from YAML/env/labels
Args:
config: Dictionary with plugin-specific configuration
"""
pass
@abstractmethod
def process(self, context: PluginContext) -> PluginResult:
"""
Process the plugin logic and return result
Args:
context: PluginContext with all necessary data
Returns:
PluginResult with HAProxy config snippets and/or modified data
"""
pass
class PluginManager:
"""Manages plugin loading, configuration, and execution"""
def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False):
"""
Initialize the plugin manager
Args:
plugins_dir: Directory containing plugin files
abort_on_error: If True, abort on plugin errors; if False, log and continue
"""
self.plugins_dir = plugins_dir
self.abort_on_error = abort_on_error
self.plugins: Dict[str, PluginInterface] = {}
self.global_plugins: List[PluginInterface] = []
self.domain_plugins: List[PluginInterface] = []
self.logger = loggerEasyHaproxy
def load_plugins(self) -> None:
"""
Discover and load plugins from the plugins directory
Loads both builtin plugins and external plugins
"""
# Load builtin plugins first
builtin_dir = os.path.join(os.path.dirname(__file__), "builtin")
self._load_plugins_from_directory(builtin_dir, "builtin")
# Load external plugins from /etc/haproxy/plugins
if os.path.exists(self.plugins_dir):
self._load_plugins_from_directory(self.plugins_dir, "external")
else:
self.logger.info(f"Plugin directory {self.plugins_dir} does not exist, skipping external plugins")
def _load_plugins_from_directory(self, directory: str, source: str) -> None:
"""
Load plugins from a specific directory
Args:
directory: Path to directory containing plugins
source: Source identifier ("builtin" or "external")
"""
if not os.path.exists(directory):
return
for filename in os.listdir(directory):
if filename.endswith(".py") and not filename.startswith("__"):
filepath = os.path.join(directory, filename)
module_name = f"plugins.{source}.{filename[:-3]}"
try:
# Load module from file
spec = importlib.util.spec_from_file_location(module_name, filepath)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
# Find plugin classes in module
for item_name in dir(module):
item = getattr(module, item_name)
if (isinstance(item, type) and
issubclass(item, PluginInterface) and
item is not PluginInterface):
# Instantiate plugin
plugin = item()
self.plugins[plugin.name] = plugin
# Categorize by type
if plugin.plugin_type == PluginType.GLOBAL:
self.global_plugins.append(plugin)
elif plugin.plugin_type == PluginType.DOMAIN:
self.domain_plugins.append(plugin)
self.logger.debug(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})")
except Exception as e:
self._handle_error(f"Failed to load plugin from {filepath}: {str(e)}")
def configure_plugins(self, plugins_config: dict) -> None:
"""
Configure all loaded plugins with their settings
Args:
plugins_config: Plugin configuration from YAML/env
Format: {"plugin_name": {"key": "value"}, ...}
"""
for plugin_name, plugin in self.plugins.items():
try:
# Get plugin-specific config
plugin_cfg = plugins_config.get(plugin_name, {})
# Also check "config" sub-key for env var configs
if "config" in plugins_config and plugin_name in plugins_config["config"]:
plugin_cfg.update(plugins_config["config"][plugin_name])
# Configure plugin
plugin.configure(plugin_cfg)
self.logger.debug(f"Configured plugin: {plugin_name} with config: {plugin_cfg}")
except Exception as e:
self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(e)}")
def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
"""
Execute all global plugins
Args:
context: PluginContext with execution data
enabled_list: Optional list of plugin names to execute. If None, execute all.
Returns:
List of PluginResult from each plugin
"""
results = []
for plugin in self.global_plugins:
# Check if plugin is in enabled list (if provided)
if enabled_list is not None and plugin.name not in enabled_list:
continue
try:
self.logger.debug(f"Executing global plugin: {plugin.name}")
result = plugin.process(context)
results.append(result)
if result.metadata:
self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}")
except Exception as e:
self._handle_error(f"Global plugin '{plugin.name}' failed: {str(e)}")
return results
def execute_domain_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
"""
Execute all domain plugins for a specific domain
Args:
context: PluginContext with domain-specific data
enabled_list: Optional list of plugin names to execute. If None, execute all.
Returns:
List of PluginResult from each plugin
"""
results = []
for plugin in self.domain_plugins:
# Check if plugin is in enabled list (if provided)
if enabled_list is not None and plugin.name not in enabled_list:
continue
try:
self.logger.debug(f"Executing domain plugin: {plugin.name} for domain: {context.domain}")
result = plugin.process(context)
results.append(result)
if result.metadata:
self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}")
except Exception as e:
self._handle_error(f"Domain plugin '{plugin.name}' failed for domain '{context.domain}': {str(e)}")
return results
def _handle_error(self, message: str) -> None:
"""
Handle plugin errors according to abort_on_error setting
Args:
message: Error message to log
"""
if self.abort_on_error:
self.logger.error(message)
raise RuntimeError(message)
else:
self.logger.warning(message)

View file

@ -21,16 +21,16 @@ Example Environment Variable:
EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600 EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600
""" """
import glob
import os import os
import sys import sys
import glob
import time import time
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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
from functions import loggerEasyHaproxy from plugins import PluginContext, PluginInterface, PluginResult, PluginType
class CleanupPlugin(PluginInterface): class CleanupPlugin(PluginInterface):
@ -66,7 +66,7 @@ class CleanupPlugin(PluginInterface):
try: try:
self.max_idle_time = int(config["max_idle_time"]) self.max_idle_time = int(config["max_idle_time"])
except ValueError: except ValueError:
loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default") logger_easyhaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
if "cleanup_temp_files" in config: if "cleanup_temp_files" in config:
self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"] self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"]
@ -104,15 +104,15 @@ class CleanupPlugin(PluginInterface):
if file_age > self.max_idle_time: if file_age > self.max_idle_time:
os.remove(filepath) os.remove(filepath)
cleanup_actions.append(f"Removed old temp file: {filepath}") cleanup_actions.append(f"Removed old temp file: {filepath}")
loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}") logger_easyhaproxy.debug(f"Cleanup plugin: Removed {filepath}")
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Failed to remove temp file {filepath}: {e}") logger_easyhaproxy.warning(f"Failed to remove temp file {filepath}: {e}")
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}") logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
# Log cleanup summary # Log cleanup summary
if cleanup_actions: if cleanup_actions:
loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)") logger_easyhaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
return PluginResult( return PluginResult(
haproxy_config="", # No HAProxy config needed for cleanup haproxy_config="", # No HAProxy config needed for cleanup

View file

@ -8,33 +8,48 @@ The plugin includes built-in Cloudflare IP ranges that are automatically
updated and written to the IP list file. updated and written to the IP list file.
Configuration: Configuration:
- ip_list_path: Path to file containing Cloudflare IP ranges (default: /etc/haproxy/cloudflare_ips.lst) - ip_list_path: Path to file containing Cloudflare IP ranges (default: /etc/easyhaproxy/cloudflare_ips.lst)
- ip_list: Base64-encoded list of IP ranges (one per line), takes precedence over ip_list_path
- use_builtin_ips: Use built-in Cloudflare IP ranges (default: true) - use_builtin_ips: Use built-in Cloudflare IP ranges (default: true)
- update_log_format: Update HAProxy log format to show real visitor IP (default: true)
Example YAML config: Example YAML config:
plugins: plugins:
cloudflare: cloudflare:
enabled: true enabled: true
ip_list_path: /etc/haproxy/cloudflare_ips.lst ip_list_path: /etc/easyhaproxy/cloudflare_ips.lst
use_builtin_ips: true use_builtin_ips: true
update_log_format: true
Example Kubernetes Ingress Annotation:
easyhaproxy.plugins: "cloudflare"
easyhaproxy.plugin.cloudflare.ip_list: "MTAuMC4wLjAvOAoxNzIuMTYuMC4wLzEyCjE5Mi4xNjguMC4wLzE2Cg=="
easyhaproxy.plugin.cloudflare.update_log_format: "true"
Example Container Label: Example Container Label:
easyhaproxy.http.plugins: "cloudflare" easyhaproxy.http.plugins: "cloudflare"
easyhaproxy.http.plugin.cloudflare.update_log_format: "true"
HAProxy Config Generated: HAProxy Config Generated:
# Cloudflare - Restore original visitor IP # Cloudflare - Restore original visitor IP
acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst acl from_cloudflare src -f /etc/easyhaproxy/cloudflare_ips.lst
http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare
http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare
Log Format (when update_log_format=true):
Shows real visitor IP alongside connection IP for debugging
Format: real_ip/connection_ip [timestamp] request status bytes ...
""" """
import base64
import os import os
import sys import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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, Consts
from functions import loggerEasyHaproxy from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
class CloudflarePlugin(PluginInterface): class CloudflarePlugin(PluginInterface):
@ -70,9 +85,11 @@ class CloudflarePlugin(PluginInterface):
] ]
def __init__(self): def __init__(self):
self.ip_list_path = "/etc/haproxy/cloudflare_ips.lst" self.ip_list_path = Consts.base_path + "/cloudflare_ips.lst"
self.enabled = True self.enabled = True
self.use_builtin_ips = True self.use_builtin_ips = True
self.update_log_format = True
self.ip_list = None
@property @property
def name(self) -> str: def name(self) -> str:
@ -89,18 +106,48 @@ class CloudflarePlugin(PluginInterface):
Args: Args:
config: Dictionary with configuration options config: Dictionary with configuration options
- ip_list_path: Path to Cloudflare IP list file - ip_list_path: Path to Cloudflare IP list file
- ip_list: Base64-encoded list of IP ranges (one per line)
- enabled: Whether plugin is enabled - enabled: Whether plugin is enabled
- use_builtin_ips: Use built-in Cloudflare IP ranges (default: true) - use_builtin_ips: Use built-in Cloudflare IP ranges (default: true)
- update_log_format: Update HAProxy log format to show real IP (default: true)
""" """
if "ip_list_path" in config: if "ip_list_path" in config:
self.ip_list_path = config["ip_list_path"] self.ip_list_path = config["ip_list_path"]
if "ip_list" in config:
# Decode from base64 (consistent with JWT validator pubkey parameter)
try:
self.ip_list = base64.b64decode(config["ip_list"]).decode('utf-8')
except Exception as e:
logger_easyhaproxy.warning(f"Cloudflare plugin: Failed to decode ip_list: {e}")
self.ip_list = None
if "enabled" in config: if "enabled" in config:
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"] self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
if "use_builtin_ips" in config: if "use_builtin_ips" in config:
self.use_builtin_ips = str(config["use_builtin_ips"]).lower() in ["true", "1", "yes"] self.use_builtin_ips = str(config["use_builtin_ips"]).lower() in ["true", "1", "yes"]
if "update_log_format" in config:
self.update_log_format = str(config["update_log_format"]).lower() in ["true", "1", "yes"]
def initialize(self) -> InitializationResult:
"""
Initialize plugin resources - create IP list directory
Returns:
InitializationResult with directory creation request
"""
# Create directory for IP list file
ip_list_dir = os.path.dirname(self.ip_list_path)
if ip_list_dir:
return InitializationResult(
resources=[
ResourceRequest(resource_type="directory", path=ip_list_dir)
]
)
return InitializationResult()
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
""" """
Generate HAProxy config to restore original IP from Cloudflare Generate HAProxy config to restore original IP from Cloudflare
@ -114,27 +161,50 @@ class CloudflarePlugin(PluginInterface):
if not self.enabled: if not self.enabled:
return PluginResult() return PluginResult()
# Write built-in Cloudflare IPs to file if using built-in IPs # Determine which IPs to write to file
if self.use_builtin_ips: ips_to_write = None
try: ip_source = None
# Create directory if it doesn't exist
ip_list_dir = os.path.dirname(self.ip_list_path)
if ip_list_dir and not os.path.exists(ip_list_dir):
os.makedirs(ip_list_dir, exist_ok=True)
# Write Cloudflare IPs to file if self.ip_list:
# Priority 1: Base64-encoded ip_list from annotation
ip_lines = [line.strip() for line in self.ip_list.split('\n') if line.strip()]
ips_to_write = ip_lines
ip_source = "base64 ip_list"
elif self.use_builtin_ips:
# Priority 2: Built-in Cloudflare IPs
ips_to_write = self.CLOUDFLARE_IPS
ip_source = "built-in IPs"
# Write IPs to file if we have any
if ips_to_write:
try:
# Write IPs to file (directory created by initialize())
with open(self.ip_list_path, 'w') as f: with open(self.ip_list_path, 'w') as f:
for ip_range in self.CLOUDFLARE_IPS: for ip_range in ips_to_write:
f.write(f"{ip_range}\n") f.write(f"{ip_range}\n")
loggerEasyHaproxy.info(f"Cloudflare plugin: Written {len(self.CLOUDFLARE_IPS)} IP ranges to {self.ip_list_path}") logger_easyhaproxy.info(
f"Cloudflare plugin: Written {len(ips_to_write)} IP ranges "
f"from {ip_source} to {self.ip_list_path}"
)
except Exception as e: except Exception as e:
loggerEasyHaproxy.warning(f"Cloudflare plugin: Failed to write IP list to {self.ip_list_path}: {e}") logger_easyhaproxy.warning(
f"Cloudflare plugin: Failed to write IP list to {self.ip_list_path}: {e}"
)
# Generate HAProxy config snippet # Generate HAProxy config snippet for backend
haproxy_config = f"""# Cloudflare - Restore original visitor IP haproxy_config = f"""# Cloudflare - Restore original visitor IP
acl from_cloudflare src -f {self.ip_list_path} acl from_cloudflare src -f {self.ip_list_path}
http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare""" http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare
http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare"""
# Generate log format config (frontend level)
# Industry-standard format: real_ip/proxy_ip [time] request status bytes timings
# Based on HAProxy HTTP log format with real IP shown first
log_format_config = None
if self.update_log_format:
log_format_config = """# Cloudflare - Enhanced log format showing real visitor IP
log-format "%{+Q}[var(txn.real_ip)]:-/%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r\""""
return PluginResult( return PluginResult(
haproxy_config=haproxy_config, haproxy_config=haproxy_config,
@ -142,7 +212,11 @@ http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_clo
metadata={ metadata={
"domain": context.domain, "domain": context.domain,
"ip_list_path": self.ip_list_path, "ip_list_path": self.ip_list_path,
"ip_list_provided": self.ip_list is not None,
"use_builtin_ips": self.use_builtin_ips, "use_builtin_ips": self.use_builtin_ips,
"ip_count": len(self.CLOUDFLARE_IPS) if self.use_builtin_ips else None "update_log_format": self.update_log_format,
} "ip_count": len(ips_to_write) if ips_to_write else None,
"ip_source": ip_source if ips_to_write else "existing file"
},
defaults_configs=[log_format_config] if log_format_config else []
) )

View file

@ -32,7 +32,7 @@ import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginContext, PluginInterface, PluginResult, PluginType
class DenyPagesPlugin(PluginInterface): class DenyPagesPlugin(PluginInterface):

View file

@ -10,7 +10,7 @@ The plugin creates:
Configuration: Configuration:
- enabled: Enable/disable the plugin (default: true) - enabled: Enable/disable the plugin (default: true)
- document_root: Document root path (default: /var/www/html) - document_root: Document root path (default: /etc/easyhaproxy/www)
- script_filename: Pattern for SCRIPT_FILENAME (default: %[path]) - script_filename: Pattern for SCRIPT_FILENAME (default: %[path])
- index_file: Default index file (default: index.php) - index_file: Default index file (default: index.php)
- path_info: Enable PATH_INFO support (default: true) - path_info: Enable PATH_INFO support (default: true)
@ -39,11 +39,12 @@ Example Kubernetes Annotation:
import os import os
import sys import sys
from functions import Consts
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginContext, PluginInterface, PluginResult, PluginType
from functions import loggerEasyHaproxy
class FastcgiPlugin(PluginInterface): class FastcgiPlugin(PluginInterface):
@ -51,7 +52,7 @@ class FastcgiPlugin(PluginInterface):
def __init__(self): def __init__(self):
self.enabled = True self.enabled = True
self.document_root = "/var/www/html" self.document_root = Consts.base_path + "/www"
self.script_filename = "%[path]" self.script_filename = "%[path]"
self.index_file = "index.php" self.index_file = "index.php"
self.path_info = True self.path_info = True
@ -124,7 +125,7 @@ class FastcgiPlugin(PluginInterface):
# PATH_INFO support # PATH_INFO support
if self.path_info: if self.path_info:
fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$") fcgi_app_lines.append(" path-info ^(/.+\\.php)(/.*)?$")
# Set SCRIPT_FILENAME if customized # Set SCRIPT_FILENAME if customized
if self.script_filename and self.script_filename != "%[path]": if self.script_filename and self.script_filename != "%[path]":
@ -137,11 +138,10 @@ class FastcgiPlugin(PluginInterface):
fcgi_app_definition = "\n".join(fcgi_app_lines) fcgi_app_definition = "\n".join(fcgi_app_lines)
# Build metadata - store fcgi_app_definition to be extracted and added to global configs # Build metadata
metadata = { metadata = {
"domain": context.domain, "domain": context.domain,
"fcgi_app_name": fcgi_app_name, "fcgi_app_name": fcgi_app_name,
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
"document_root": self.document_root, "document_root": self.document_root,
"index_file": self.index_file, "index_file": self.index_file,
"path_info": self.path_info, "path_info": self.path_info,
@ -151,5 +151,6 @@ class FastcgiPlugin(PluginInterface):
return PluginResult( return PluginResult(
haproxy_config=backend_config, # use-fcgi-app directive for the backend haproxy_config=backend_config, # use-fcgi-app directive for the backend
modified_easymapping=None, modified_easymapping=None,
metadata=metadata metadata=metadata,
global_configs=[fcgi_app_definition] # For top-level injection
) )

View file

@ -33,7 +33,7 @@ import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from plugins import PluginContext, PluginInterface, PluginResult, PluginType
class IpWhitelistPlugin(PluginInterface): class IpWhitelistPlugin(PluginInterface):

View file

@ -9,12 +9,28 @@ Configuration:
- algorithm: JWT signing algorithm (default: RS256) - algorithm: JWT signing algorithm (default: RS256)
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation) - issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
- audience: Expected JWT audience (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_path: Path to public key file in container (priority: 1)
- pubkey: Public key content as base64-encoded string (required if pubkey_path not provided) - pubkey: Public key content as base64-encoded string (priority: 2)
- k8s_secret.pubkey: Kubernetes secret containing public key (priority: 3, Kubernetes only)
- paths: List of paths that require JWT validation (optional, if not set ALL domain is protected) - 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 - only_paths: If true, only specified paths are accessible; if false (default), only specified paths require JWT validation
- allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header - allow_anonymous: If true, allows requests without Authorization header (validates JWT if present); if false (default), requires Authorization header
Priority Order (first configured option wins):
1. pubkey_path - Direct file path (explicit configuration)
2. pubkey - Base64-encoded key content (inline configuration)
3. k8s_secret.pubkey - Kubernetes secret name (processed by K8s processor into pubkey)
Kubernetes Secret Pattern (Kubernetes only):
For Kubernetes deployments, you can load the public key from a Kubernetes Secret:
- Auto-detect key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name"
- Explicit key: easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "secret_name/key_name"
See documentation for details:
- General k8s_secret pattern: docs/kubernetes.md#loading-plugin-configuration-from-kubernetes-secrets
- JWT Validator with Secrets: docs/Plugins/jwt-validator.md#kubernetes-with-secrets-recommended
Path Validation Logic: Path Validation Logic:
- No paths configured: ALL requests to the domain require JWT validation (default behavior) - 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=false: Only specified paths require JWT validation, others pass through
@ -31,7 +47,7 @@ Example YAML config:
algorithm: RS256 algorithm: RS256
issuer: https://myaccount.auth0.com/ issuer: https://myaccount.auth0.com/
audience: https://api.mywebsite.com audience: https://api.mywebsite.com
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem pubkey_path: /etc/easyhaproxy/jwt_keys/pubkey.pem
paths: paths:
- /api/admin - /api/admin
- /api/sensitive - /api/sensitive
@ -42,10 +58,20 @@ Example Container Label:
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256 easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/ 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.audience: https://api.example.com
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem 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.paths: /api/admin,/api/sensitive
easyhaproxy.http.plugin.jwt_validator.only_paths: true easyhaproxy.http.plugin.jwt_validator.only_paths: true
Example Kubernetes Annotations:
# Using k8s_secret pattern (recommended for Kubernetes):
easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey: "my-jwt-secret"
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
# Using inline pubkey (for testing):
easyhaproxy.plugin.jwt_validator.pubkey: "LS0tLS1CRUdJTi..."
HAProxy Config Generated: HAProxy Config Generated:
# JWT Validator - Validate JWT tokens # JWT Validator - Validate JWT tokens
http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found } http-request deny content-type 'text/html' string 'Missing Authorization HTTP header' unless { req.hdr(authorization) -m found }
@ -60,7 +86,7 @@ HAProxy Config Generated:
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 '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 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 audience' unless { var(txn.aud) -m str https://api.example.com }
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } 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 # Validate expiration
http-request set-var(txn.now) date() http-request set-var(txn.now) date()
@ -74,8 +100,8 @@ import sys
# Add parent directory to path for imports # Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from plugins import PluginInterface, PluginType, PluginContext, PluginResult from functions import Functions, logger_easyhaproxy, Consts
from functions import loggerEasyHaproxy from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
class JwtValidatorPlugin(PluginInterface): class JwtValidatorPlugin(PluginInterface):
@ -91,6 +117,8 @@ class JwtValidatorPlugin(PluginInterface):
self.paths = [] # List of paths that require JWT validation self.paths = [] # List of paths that require JWT validation
self.only_paths = False # If true, only specified paths are accessible self.only_paths = False # If true, only specified paths are accessible
self.allow_anonymous = False # If true, allow requests without Authorization header self.allow_anonymous = False # If true, allow requests without Authorization header
# Make JWT_KEYS_DIR configurable via environment variable (for testing)
self.jwt_keys_dir = os.getenv("EASYHAPROXY_JWT_KEYS_DIR", Consts.base_path + "/jwt_keys")
@property @property
def name(self) -> str: def name(self) -> str:
@ -159,6 +187,19 @@ class JwtValidatorPlugin(PluginInterface):
if "allow_anonymous" in config: if "allow_anonymous" in config:
self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"] self.allow_anonymous = str(config["allow_anonymous"]).lower() in ["true", "1", "yes"]
def initialize(self) -> InitializationResult:
"""
Initialize plugin resources - create JWT keys directory
Returns:
InitializationResult with directory creation request
"""
return InitializationResult(
resources=[
ResourceRequest(resource_type="directory", path=self.jwt_keys_dir)
]
)
def process(self, context: PluginContext) -> PluginResult: def process(self, context: PluginContext) -> PluginResult:
""" """
Generate HAProxy config to validate JWT tokens Generate HAProxy config to validate JWT tokens
@ -178,9 +219,20 @@ class JwtValidatorPlugin(PluginInterface):
elif self.pubkey: elif self.pubkey:
# Generate path for pubkey based on domain # Generate path for pubkey based on domain
domain_safe = context.domain.replace(".", "_").replace(":", "_") domain_safe = context.domain.replace(".", "_").replace(":", "_")
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem" pubkey_file = f"{self.jwt_keys_dir}/{domain_safe}_pubkey.pem"
# Write the public key file (with error handling for test environments)
try:
# Ensure directory exists (defensive - normally created by initialize())
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:
# In test environments or restricted environments, file write may fail
# This is okay - the config is still generated correctly
logger_easyhaproxy.debug(f"Could not write JWT public key file (may be test environment): {e}")
else: else:
loggerEasyHaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured") logger_easyhaproxy.warning(f"JWT validator plugin for {context.domain}: No pubkey or pubkey_path configured")
return PluginResult() return PluginResult()
# Build HAProxy configuration # Build HAProxy configuration
@ -271,6 +323,7 @@ class JwtValidatorPlugin(PluginInterface):
if self.audience: if self.audience:
metadata["audience"] = self.audience metadata["audience"] = self.audience
if self.pubkey: if self.pubkey:
# Keep pubkey_content in metadata for backward compatibility with tests
metadata["pubkey_content"] = self.pubkey metadata["pubkey_content"] = self.pubkey
if self.paths: if self.paths:
metadata["paths"] = self.paths metadata["paths"] = self.paths

51
src/plugins/interface.py Normal file
View file

@ -0,0 +1,51 @@
from abc import ABC, abstractmethod
from .types import InitializationResult, PluginContext, PluginResult, PluginType
class PluginInterface(ABC):
"""Base class all plugins must inherit"""
@property
@abstractmethod
def name(self) -> str:
"""Return the unique plugin name"""
pass
@property
@abstractmethod
def plugin_type(self) -> PluginType:
"""Return the plugin type (GLOBAL or DOMAIN)"""
pass
@abstractmethod
def configure(self, config: dict) -> None:
"""
Configure the plugin with settings from YAML/env/labels
Args:
config: Dictionary with plugin-specific configuration
"""
pass
@abstractmethod
def process(self, context: PluginContext) -> PluginResult:
"""
Process the plugin logic and return result
Args:
context: PluginContext with all necessary data
Returns:
PluginResult with HAProxy config snippets and/or modified data
"""
pass
def initialize(self) -> InitializationResult:
"""
Initialize plugin resources. Default: no-op for backward compatibility
Returns:
InitializationResult with resource requests
"""
return InitializationResult()

216
src/plugins/manager.py Normal file
View file

@ -0,0 +1,216 @@
import importlib.util
import os
import sys
from functions import Consts, logger_easyhaproxy
from .interface import PluginInterface
from .types import PluginContext, PluginResult, PluginType
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
"""
self.plugins_dir = plugins_dir or os.getenv(
"EASYHAPROXY_PLUGINS_DIR",
Consts.base_path + "/plugins"
)
self.abort_on_error = abort_on_error
self.plugins: dict[str, PluginInterface] = {}
self.global_plugins: list[PluginInterface] = []
self.domain_plugins: list[PluginInterface] = []
self.logger = logger_easyhaproxy
def load_plugins(self) -> None:
"""
Discover and load plugins from the plugins directory
Loads both builtin plugins and external plugins
"""
# Load builtin plugins first
builtin_dir = os.path.join(os.path.dirname(__file__), "builtin")
self._load_plugins_from_directory(builtin_dir, "builtin")
# Load external plugins from /etc/easyhaproxy/plugins
if os.path.exists(self.plugins_dir):
self._load_plugins_from_directory(self.plugins_dir, "external")
else:
self.logger.debug(f"Plugin directory {self.plugins_dir} does not exist, skipping external plugins")
def _load_plugins_from_directory(self, directory: str, source: str) -> None:
"""
Load plugins from a specific directory
Args:
directory: Path to directory containing plugins
source: Source identifier ("builtin" or "external")
"""
if not os.path.exists(directory):
return
for filename in os.listdir(directory):
if filename.endswith(".py") and not filename.startswith("__"):
filepath = os.path.join(directory, filename)
module_name = f"plugins.{source}.{filename[:-3]}"
try:
# Load module from file
spec = importlib.util.spec_from_file_location(module_name, filepath)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
# Find plugin classes in module
for item_name in dir(module):
item = getattr(module, item_name)
if (isinstance(item, type) and
issubclass(item, PluginInterface) and
item is not PluginInterface):
# Instantiate plugin
plugin = item()
self.plugins[plugin.name] = plugin
# Categorize by type
if plugin.plugin_type == PluginType.GLOBAL:
self.global_plugins.append(plugin)
elif plugin.plugin_type == PluginType.DOMAIN:
self.domain_plugins.append(plugin)
self.logger.debug(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})")
except Exception as e:
self._handle_error(f"Failed to load plugin from {filepath}: {str(e)}")
def configure_plugins(self, plugins_config: dict) -> None:
"""
Configure all loaded plugins with their settings
Args:
plugins_config: Plugin configuration from YAML/env
Format: {"plugin_name": {"key": "value"}, ...}
"""
for plugin_name, plugin in self.plugins.items():
try:
# Get plugin-specific config
plugin_cfg = plugins_config.get(plugin_name, {})
# Also check "config" sub-key for env var configs
if "config" in plugins_config and plugin_name in plugins_config["config"]:
plugin_cfg.update(plugins_config["config"][plugin_name])
# Configure plugin
plugin.configure(plugin_cfg)
self.logger.debug(f"Configured plugin: {plugin_name} with config: {plugin_cfg}")
except Exception as e:
self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(e)}")
def initialize_plugins(self) -> None:
"""Initialize all plugins and process their resource requests"""
for plugin_name, plugin in self.plugins.items():
try:
result = plugin.initialize()
self._process_initialization_result(plugin_name, result)
except Exception as e:
self._handle_error(f"Plugin '{plugin_name}' initialization failed: {e}")
def _process_initialization_result(self, plugin_name: str, result) -> None:
"""
Process plugin initialization requests
Args:
plugin_name: Name of the plugin
result: InitializationResult with resource requests
"""
for resource in result.resources:
if resource.resource_type == "directory":
os.makedirs(resource.path, exist_ok=True)
self.logger.debug(f"Plugin '{plugin_name}' created directory: {resource.path}")
elif resource.resource_type == "file":
if resource.overwrite or not os.path.exists(resource.path):
with open(resource.path, 'w') as f:
f.write(resource.content or "")
self.logger.debug(f"Plugin '{plugin_name}' created file: {resource.path}")
def execute_global_plugins(self, context: PluginContext, enabled_list: list[str] | None = None) -> list[PluginResult]:
"""
Execute all global plugins
Args:
context: PluginContext with execution data
enabled_list: Optional list of plugin names to execute. If None, execute all.
Returns:
List of PluginResult from each plugin
"""
results = []
for plugin in self.global_plugins:
# Check if plugin is in enabled list (if provided)
if enabled_list is not None and plugin.name not in enabled_list:
continue
try:
self.logger.debug(f"Executing global plugin: {plugin.name}")
result = plugin.process(context)
results.append(result)
if result.metadata:
self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}")
except Exception as e:
self._handle_error(f"Global plugin '{plugin.name}' failed: {str(e)}")
return results
def execute_domain_plugins(self, context: PluginContext, enabled_list: list[str] | None = None) -> list[PluginResult]:
"""
Execute all domain plugins for a specific domain
Args:
context: PluginContext with domain-specific data
enabled_list: Optional list of plugin names to execute. If None, execute all.
Returns:
List of PluginResult from each plugin
"""
results = []
for plugin in self.domain_plugins:
# Check if plugin is in enabled list (if provided)
if enabled_list is not None and plugin.name not in enabled_list:
continue
try:
self.logger.debug(f"Executing domain plugin: {plugin.name} for domain: {context.domain}")
result = plugin.process(context)
results.append(result)
if result.metadata:
self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}")
except Exception as e:
self._handle_error(f"Domain plugin '{plugin.name}' failed for domain '{context.domain}': {str(e)}")
return results
def _handle_error(self, message: str) -> None:
"""
Handle plugin errors according to abort_on_error setting
Args:
message: Error message to log
"""
if self.abort_on_error:
self.logger.error(message)
raise RuntimeError(message)
else:
self.logger.warning(message)

46
src/plugins/types.py Normal file
View file

@ -0,0 +1,46 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
class PluginType(Enum):
"""Plugin execution types"""
GLOBAL = "global" # Execute once per discovery cycle
DOMAIN = "domain" # Execute per domain/host
@dataclass
class PluginContext:
"""Container for all plugin execution data"""
parsed_object: dict # {IP: labels} from discovery
easymapping: list # Current HAProxy mapping structure
container_env: dict # Environment configuration
domain: str | None = None # Domain name (for DOMAIN plugins)
port: str | None = None # Port (for DOMAIN plugins)
host_config: dict | None = None # Domain-specific config
@dataclass
class ResourceRequest:
"""Request for file system resources"""
resource_type: str # "directory" or "file"
path: str
content: str | None = None
overwrite: bool = False
@dataclass
class InitializationResult:
"""Plugin initialization result with resource requests"""
resources: list[ResourceRequest] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class PluginResult:
"""Plugin execution result"""
haproxy_config: str = "" # HAProxy config snippet to inject
modified_easymapping: list | None = None # Modified easymapping structure
metadata: dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging
global_configs: list[str] = field(default_factory=list) # HAProxy global-level configs
defaults_configs: list[str] = field(default_factory=list) # HAProxy defaults-level configs

View file

@ -1,328 +1,7 @@
import base64 from .docker import Docker
import socket from .interface import ProcessorInterface
from typing import Final from .kubernetes import Kubernetes
from .static import Static
from .swarm import Swarm
import docker __all__ = ["ProcessorInterface", "Static", "Docker", "Swarm", "Kubernetes"]
import yaml
from kubernetes import client, config
from kubernetes.client.rest import ApiException
from easymapping import HaproxyConfigGenerator
from functions import Functions, Consts, ContainerEnv
from functions import loggerEasyHaproxy
class ProcessorInterface:
STATIC: Final[str] = "static"
DOCKER: Final[str] = "docker"
SWARM: Final[str] = "swarm"
KUBERNETES: Final[str] = "kubernetes"
static_file = Consts.easyhaproxy_config
def __init__(self, filename=None):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.cfg = None
self.certbot_hosts = None
self.hosts = None
self.filename = filename
self.label = ContainerEnv.read()['lookup_label']
self.refresh()
@staticmethod
def factory(mode):
if mode == ProcessorInterface.STATIC:
return Static(ProcessorInterface.static_file)
elif mode == ProcessorInterface.DOCKER:
return Docker()
elif mode == ProcessorInterface.SWARM:
return Swarm()
elif mode == ProcessorInterface.KUBERNETES:
return Kubernetes()
else:
loggerEasyHaproxy.fatal("Expected mode to be 'static', 'docker', 'swarm' or 'kubernetes'. I got '%s'" % mode)
return None
def refresh(self):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.inspect_network()
self.parse()
def inspect_network(self):
# Abstract
pass
def parse(self):
self.cfg = HaproxyConfigGenerator(ContainerEnv.read())
def get_certbot_hosts(self):
return self.certbot_hosts
def get_hosts(self):
return self.hosts
def get_parsed_object(self):
return self.parsed_object
def get_certs(self, key=None):
if key is None:
return self.cfg.certs
else:
return None if key not in self.cfg.certs else self.cfg.certs[key]
def get_haproxy_conf(self):
conf = self.cfg.generate(self.parsed_object)
self.certbot_hosts = self.cfg.certbot_hosts
self.hosts = self.cfg.serving_hosts
return conf
def save_config(self, filename):
Functions.save(filename, self.get_haproxy_conf())
def save_certs(self, path):
for cert in self.get_certs():
Functions.save("{0}/{1}".format(path, cert), self.get_certs(cert))
class Static(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.static_content = None
self.static_content = None
self.cfg = None
super().__init__(filename)
def inspect_network(self):
self.parsed_object = {}
self.static_content = None
def get_parsed_object(self):
return self.static_content["easymapping"] if "easymapping" in self.static_content else []
def get_hosts(self):
hosts = []
for obj in self.get_parsed_object():
if "hosts" not in obj:
continue
for host in obj["hosts"].keys():
hosts.append("%s:%s" % (host, obj["port"]))
return hosts
def parse(self):
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
# Merge plugin config from YAML with env vars
if "plugins" in self.static_content:
# Get env var config
container_env = ContainerEnv.read()
# Merge YAML plugins config with env config
# YAML config takes precedence over env vars
if "plugins" not in self.static_content:
self.static_content["plugins"] = container_env.get("plugins", {})
else:
# Merge configs - YAML overrides env vars
yaml_plugins = self.static_content["plugins"]
env_plugins = container_env.get("plugins", {})
# Merge individual plugin configs
for plugin_name, plugin_config in env_plugins.get("config", {}).items():
if plugin_name not in yaml_plugins:
yaml_plugins[plugin_name] = {}
# Env vars fill in missing keys, YAML takes precedence
for key, value in plugin_config.items():
if key not in yaml_plugins[plugin_name]:
yaml_plugins[plugin_name][key] = value
self.cfg = HaproxyConfigGenerator(self.static_content)
class Docker(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
try:
ha_proxy_network_name = next(
iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"]))
except:
# HAProxy is not running in a container, get first container network
if len(self.client.containers.list()) == 0:
return
ha_proxy_network_name = next(iter(
self.client.containers.get(self.client.containers.list()[0].name).attrs["NetworkSettings"]["Networks"]))
ha_proxy_network = self.client.networks.get(ha_proxy_network_name)
self.parsed_object = {}
for container in self.client.containers.list():
# Issue 32 - Docker container cannot connect to containers in different network.
if ha_proxy_network_name not in container.attrs["NetworkSettings"]["Networks"].keys():
ha_proxy_network.connect(container.name)
container = self.client.containers.get(container.name) # refresh object
ip_address = container.attrs["NetworkSettings"]["Networks"][ha_proxy_network_name]["IPAddress"]
self.parsed_object[ip_address] = container.labels
class Swarm(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
ha_proxy_service_name = self.client.containers.get(socket.gethostname()).name.split('.')[0]
ha_proxy_network_id = None
swarm_ingress_id = None
# Get the HAProxy network and the ingress network
for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]:
network_name = self.client.networks.get(endpoint["NetworkID"]).name
if swarm_ingress_id is None and network_name == 'ingress':
swarm_ingress_id = endpoint["NetworkID"]
if ha_proxy_network_id is None and network_name != 'ingress':
ha_proxy_network_id = endpoint["NetworkID"]
if ha_proxy_network_id is not None and swarm_ingress_id is not None:
break
# Check if the service is attached to the HAProxy network
self.parsed_object = {}
for service in self.client.services.list():
if not any(self.label in key for key in service.attrs["Spec"]["Labels"]):
continue
ip_address = None
network_list = []
for endpoint in service.attrs["Endpoint"]["VirtualIPs"]:
if ha_proxy_network_id == endpoint["NetworkID"]:
ip_address = endpoint["Addr"].split("/")[0]
break
elif swarm_ingress_id != endpoint["NetworkID"]:
network_list.append(endpoint["NetworkID"])
# Attach the service to the HAProxy network
if ip_address is None:
network_list.append(ha_proxy_network_id)
service.update(networks = network_list)
continue # skip to the next service to give time to update the network
self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"]
class Kubernetes(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
config.load_incluster_config()
config.verify_ssl = False
self.api_instance = client.CoreV1Api()
self.v1 = client.NetworkingV1Api()
self.cert_cache = {}
super().__init__()
def _check_annotation(self, annotations, key, default=None):
if key not in annotations:
return default
return annotations[key]
def inspect_network(self):
ret = self.v1.list_ingress_for_all_namespaces(watch=False)
self.parsed_object = {}
for ingress in ret.items:
if 'kubernetes.io/ingress.class' not in ingress.metadata.annotations:
continue
if ingress.metadata.annotations['kubernetes.io/ingress.class'] != "easyhaproxy-ingress":
continue
ssl_hosts = []
certbot = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.certbot")
redirect_ssl = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect_ssl")
redirect = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.redirect")
mode = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.mode")
listen_port = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.listen_port", 80)
plugins = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.plugins")
# Extract plugin-specific configurations
plugin_annotations = {}
for annotation_key, annotation_value in ingress.metadata.annotations.items():
if annotation_key.startswith("easyhaproxy.plugin."):
plugin_annotations[annotation_key] = annotation_value
data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"),
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
ingress_name = ingress.metadata.namespace
if ingress.spec.tls is not None:
for tls in ingress.spec.tls:
try:
secret = self.api_instance.read_namespaced_secret(tls.secret_name, ingress.metadata.namespace)
if "tls.crt" not in secret.data or "tls.key" not in secret.data:
continue
if tls.secret_name not in self.cert_cache or self.cert_cache[tls.secret_name] != secret.data:
self.cert_cache[tls.secret_name] = secret.data
Functions.save(
"{0}/{1}.pem".format(Consts.certs_haproxy, tls.secret_name),
base64.b64decode(secret.data["tls.crt"]).decode('ascii') + "\n" + base64.b64decode(
secret.data["tls.key"]).decode('ascii')
)
ssl_hosts.extend(tls.hosts)
except Exception as e:
loggerEasyHaproxy.warn("Ingress %s - Get secret failed: '%s'" % (ingress_name, e))
loggerEasyHaproxy.debug("Ingress %s - SSL Hosts found '%s'" % (ingress_name, ssl_hosts))
for rule in ingress.spec.rules:
rule_data = {}
port_number = rule.http.paths[0].backend.service.port.number
definition = "easyhaproxy.%s_%s" % (rule.host.replace(".", "-"), port_number)
rule_data["%s.host" % definition] = rule.host
rule_data["%s.port" % definition] = listen_port
rule_data["%s.localport" % definition] = port_number
if rule.host in ssl_hosts:
rule_data["%s.clone_to_ssl" % definition] = 'true'
if redirect_ssl is not None:
rule_data["%s.redirect_ssl" % definition] = redirect_ssl
if certbot is not None:
rule_data["%s.certbot" % definition] = certbot
if redirect is not None:
rule_data["%s.redirect" % definition] = redirect
if mode is not None:
rule_data["%s.mode" % definition] = mode
rule_data["%s.balance" % definition] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin")
# Add plugin configuration
if plugins is not None:
rule_data["%s.plugins" % definition] = plugins
# Add plugin-specific configurations
for plugin_key, plugin_value in plugin_annotations.items():
# Convert easyhaproxy.plugin.X.Y to easyhaproxy.{definition}.plugin.X.Y
plugin_config_key = plugin_key.replace("easyhaproxy.plugin.", "%s.plugin." % definition)
rule_data[plugin_config_key] = plugin_value
service_name = rule.http.paths[0].backend.service.name
try:
api_response = self.api_instance.read_namespaced_service(service_name, ingress.metadata.namespace)
cluster_ip = api_response.spec.cluster_ip
except ApiException as e:
cluster_ip = None
loggerEasyHaproxy.warn("Ingress %s - Service %s - Failed: '%s'" % (ingress_name, service_name, e))
if cluster_ip is not None:
if cluster_ip not in self.parsed_object.keys():
self.parsed_object[cluster_ip] = data
self.parsed_object[cluster_ip].update(rule_data)

35
src/processor/docker.py Normal file
View file

@ -0,0 +1,35 @@
import socket
import docker
from .interface import ProcessorInterface
class Docker(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
try:
ha_proxy_network_name = next(
iter(self.client.containers.get(socket.gethostname()).attrs["NetworkSettings"]["Networks"]))
except Exception:
# HAProxy is not running in a container, get first container network
if len(self.client.containers.list()) == 0:
return
ha_proxy_network_name = next(iter(
self.client.containers.get(self.client.containers.list()[0].name).attrs["NetworkSettings"]["Networks"]))
ha_proxy_network = self.client.networks.get(ha_proxy_network_name)
self.parsed_object = {}
for container in self.client.containers.list():
# Issue 32 - Docker container cannot connect to containers in different network.
if ha_proxy_network_name not in container.attrs["NetworkSettings"]["Networks"].keys():
ha_proxy_network.connect(container.name)
container = self.client.containers.get(container.name) # refresh object
ip_address = container.attrs["NetworkSettings"]["Networks"][ha_proxy_network_name]["IPAddress"]
self.parsed_object[ip_address] = container.labels

View file

@ -0,0 +1,87 @@
from typing import Final
from easymapping import HaproxyConfigGenerator
from functions import Consts, ContainerEnv, Functions, logger_easyhaproxy
class ProcessorInterface:
STATIC: Final[str] = "static"
DOCKER: Final[str] = "docker"
SWARM: Final[str] = "swarm"
KUBERNETES: Final[str] = "kubernetes"
static_file = Consts.easyhaproxy_config
def __init__(self, filename=None):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.cfg = None
self.certbot_hosts = None
self.hosts = None
self.filename = filename
self.label = ContainerEnv.read()['lookup_label']
self.refresh()
@staticmethod
def factory(mode):
from .static import Static
from .docker import Docker
from .swarm import Swarm
from .kubernetes import Kubernetes
if mode == ProcessorInterface.STATIC:
return Static(ProcessorInterface.static_file)
elif mode == ProcessorInterface.DOCKER:
return Docker()
elif mode == ProcessorInterface.SWARM:
return Swarm()
elif mode == ProcessorInterface.KUBERNETES:
return Kubernetes()
else:
logger_easyhaproxy.fatal(f"Expected mode to be 'static', 'docker', 'swarm' or 'kubernetes'. I got '{mode}'")
return None
def refresh(self):
self.certbot_hosts = None
self.parsed_object = None
self.cfg = None
self.hosts = None
self.inspect_network()
self.parse()
def inspect_network(self):
# Abstract
pass
def parse(self):
self.cfg = HaproxyConfigGenerator(ContainerEnv.read())
def get_certbot_hosts(self):
return self.certbot_hosts
def get_hosts(self):
return self.hosts
def get_parsed_object(self):
return self.parsed_object
def get_certs(self, key=None):
if key is None:
return self.cfg.certs
else:
return None if key not in self.cfg.certs else self.cfg.certs[key]
def get_haproxy_conf(self):
conf = self.cfg.generate(self.parsed_object)
self.certbot_hosts = self.cfg.certbot_hosts
self.hosts = self.cfg.serving_hosts
return conf
def save_config(self, filename):
Functions.save(filename, self.get_haproxy_conf())
def save_certs(self, path):
for cert in self.get_certs():
Functions.save(f"{path}/{cert}", self.get_certs(cert))

462
src/processor/kubernetes.py Normal file
View file

@ -0,0 +1,462 @@
import base64
import os
import socket
import time
from kubernetes import client, config
from kubernetes.client.rest import ApiException
from functions import Consts, ContainerEnv, Functions, logger_easyhaproxy
from .interface import ProcessorInterface
class Kubernetes(ProcessorInterface):
def __init__(self, filename=None, api_instance=None, v1=None):
self.parsed_object = None
# Only load config if API clients are not provided (allows dependency injection for testing)
if api_instance is None or v1 is None:
config.load_incluster_config()
config.verify_ssl = False
# Use injected clients or create new ones (dependency injection pattern)
self.api_instance = api_instance or client.CoreV1Api()
self.v1 = v1 or client.NetworkingV1Api()
self.cert_cache = {}
self.deployment_mode_cache = None
self.ingress_addresses_cache = None
self.addresses_cache_time = 0
super().__init__()
def _detect_deployment_mode(self):
"""
Detect the deployment mode (daemonset, nodeport, clusterip).
Returns: tuple (mode: str, service: V1Service or None)
"""
# Return cached if available
if self.deployment_mode_cache:
return self.deployment_mode_cache
env_config = ContainerEnv.read()
# Check for manual override
if env_config['deployment_mode'] != 'auto':
logger_easyhaproxy.info(f"Using manual deployment mode: {env_config['deployment_mode']}")
service = self._get_easyhaproxy_service() if env_config['deployment_mode'] in ['nodeport', 'clusterip'] else None
self.deployment_mode_cache = (env_config['deployment_mode'], service)
return self.deployment_mode_cache
try:
# Get current pod name from hostname
pod_name = socket.gethostname()
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
# Read current pod
pod = self.api_instance.read_namespaced_pod(pod_name, namespace)
# Check owner references to determine if DaemonSet or Deployment
if pod.metadata.owner_references:
owner_kind = pod.metadata.owner_references[0].kind
if owner_kind == 'DaemonSet':
logger_easyhaproxy.info("Detected deployment mode: daemonset")
self.deployment_mode_cache = ('daemonset', None)
return self.deployment_mode_cache
elif owner_kind in ['ReplicaSet', 'Deployment']:
# Check if Service exists
service = self._get_easyhaproxy_service()
if service:
if service.spec.type == 'NodePort':
logger_easyhaproxy.info("Detected deployment mode: nodeport")
self.deployment_mode_cache = ('nodeport', service)
return self.deployment_mode_cache
else:
logger_easyhaproxy.info("Detected deployment mode: clusterip")
self.deployment_mode_cache = ('clusterip', service)
return self.deployment_mode_cache
except Exception as e:
logger_easyhaproxy.warn(f"Failed to detect deployment mode: {e}, defaulting to daemonset")
self.deployment_mode_cache = ('daemonset', None)
return self.deployment_mode_cache
def _get_easyhaproxy_service(self):
"""Get the EasyHAProxy service if it exists."""
try:
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
# Try common service names
service_names = ['easyhaproxy', 'ingress-easyhaproxy']
for service_name in service_names:
try:
service = self.api_instance.read_namespaced_service(service_name, namespace)
return service
except Exception:
continue
return None
except Exception as e:
logger_easyhaproxy.warn(f"Failed to get EasyHAProxy service: {e}")
return None
def _get_ingress_addresses(self, mode, service):
"""
Get IP addresses or hostnames to report in ingress status.
Args:
mode: Deployment mode (daemonset, nodeport, clusterip)
service: V1Service object (for nodeport/clusterip modes)
Returns:
List of dicts: [{"ip": "..."}, {"hostname": "..."}]
"""
env_config = ContainerEnv.read()
cache_ttl = env_config.get('ingress_status_update_interval', 30)
# Return cached if still valid
if self.ingress_addresses_cache and (time.time() - self.addresses_cache_time) < cache_ttl:
return self.ingress_addresses_cache
addresses = []
try:
if mode == 'daemonset':
# Get nodes where DaemonSet pods are running
namespace = os.getenv('POD_NAMESPACE', 'easyhaproxy')
label_selector = "app.kubernetes.io/name=easyhaproxy"
pods = self.api_instance.list_namespaced_pod(namespace, label_selector=label_selector)
node_names = set(pod.spec.node_name for pod in pods.items if pod.spec.node_name)
# Get external IPs from these nodes
for node_name in node_names:
node = self.api_instance.read_node(node_name)
for addr in node.status.addresses:
if addr.type == 'ExternalIP':
addresses.append({"ip": addr.address})
break
else:
# Fallback to InternalIP if no ExternalIP
for addr in node.status.addresses:
if addr.type == 'InternalIP':
addresses.append({"ip": addr.address})
break
elif mode == 'nodeport':
# Get all node IPs (traffic can reach any node via NodePort)
nodes = self.api_instance.list_node()
for node in nodes.items:
for addr in node.status.addresses:
if addr.type == 'ExternalIP':
addresses.append({"ip": addr.address})
break
else:
# Fallback to InternalIP
for addr in node.status.addresses:
if addr.type == 'InternalIP':
addresses.append({"ip": addr.address})
break
elif mode == 'clusterip':
# Check if LoadBalancer status is available
if service and service.status and service.status.load_balancer:
lb_ingress = service.status.load_balancer.ingress or []
for ing in lb_ingress:
if ing.ip:
addresses.append({"ip": ing.ip})
if ing.hostname:
addresses.append({"hostname": ing.hostname})
# If no LoadBalancer, check for external hostname override
if not addresses and env_config['external_hostname']:
addresses.append({"hostname": env_config['external_hostname']})
# Fallback to ClusterIP
if not addresses and service:
addresses.append({"ip": service.spec.cluster_ip})
except Exception as e:
logger_easyhaproxy.warn(f"Failed to get ingress addresses: {e}")
# Cache the result
self.ingress_addresses_cache = addresses
self.addresses_cache_time = time.time()
return addresses
def _update_ingress_status(self, ingress, addresses):
"""
Update the status of an ingress resource.
Args:
ingress: V1Ingress object
addresses: List of address dicts [{"ip": "..."}, {"hostname": "..."}]
"""
if not addresses:
return
try:
# Create status patch
status_body = {
"status": {
"loadBalancer": {
"ingress": addresses
}
}
}
# Update status using patch (not replace)
self.v1.patch_namespaced_ingress_status(
name=ingress.metadata.name,
namespace=ingress.metadata.namespace,
body=status_body,
field_manager="easyhaproxy"
)
logger_easyhaproxy.debug(
f"Updated ingress {ingress.metadata.namespace}/{ingress.metadata.name} "
f"status with {len(addresses)} address(es)"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Failed to update status for ingress "
f"{ingress.metadata.namespace}/{ingress.metadata.name}: {e}"
)
def _check_annotation(self, annotations, key, default=None):
if key not in annotations:
return default
return annotations[key]
def inspect_network(self):
ret = self.v1.list_ingress_for_all_namespaces(watch=False)
# Detect deployment mode once per cycle for ingress status updates
env_config = ContainerEnv.read()
if env_config['update_ingress_status']:
deployment_mode, service = self._detect_deployment_mode()
ingress_addresses = self._get_ingress_addresses(deployment_mode, service)
else:
ingress_addresses = []
self.parsed_object = {}
for ingress in ret.items:
# Support both new spec.ingressClassName and deprecated annotation for backward compatibility
ingress_class = None
is_match = False
# Check new spec.ingressClassName first (preferred)
if hasattr(ingress.spec, 'ingress_class_name') and ingress.spec.ingress_class_name is not None:
ingress_class = ingress.spec.ingress_class_name
# Modern spec uses 'easyhaproxy'
is_match = (ingress_class == "easyhaproxy")
# Fall back to deprecated annotation
elif ingress.metadata.annotations and 'kubernetes.io/ingress.class' in ingress.metadata.annotations:
ingress_class = ingress.metadata.annotations['kubernetes.io/ingress.class']
# Deprecated annotation uses 'easyhaproxy-ingress' for backward compatibility
is_match = (ingress_class == "easyhaproxy-ingress")
# Skip if no ingress class is defined or it doesn't match
if not is_match:
continue
ssl_hosts = []
annotations = ingress.metadata.annotations or {}
certbot = self._check_annotation(annotations, "easyhaproxy.certbot")
redirect_ssl = self._check_annotation(annotations, "easyhaproxy.redirect_ssl")
redirect = self._check_annotation(annotations, "easyhaproxy.redirect")
mode = self._check_annotation(annotations, "easyhaproxy.mode")
listen_port = self._check_annotation(annotations, "easyhaproxy.listen_port", 80)
plugins = self._check_annotation(annotations, "easyhaproxy.plugins")
# Extract plugin-specific configurations
plugin_annotations = {}
for annotation_key, annotation_value in annotations.items():
if annotation_key.startswith("easyhaproxy.plugin."):
plugin_annotations[annotation_key] = annotation_value
# Get ingress name for logging
ingress_name = f"{ingress.metadata.namespace}/{ingress.metadata.name}"
# Generic k8s_secret annotation processing
# Pattern: easyhaproxy.plugin.X.k8s_secret.KEY: "secret_name" or "secret_name/key_name"
# Result: easyhaproxy.plugin.X.KEY: "<base64-encoded-content>"
k8s_secret_annotations = {}
for annotation_key, secret_value in list(plugin_annotations.items()):
# Check if this annotation contains k8s_secret pattern
if ".k8s_secret." in annotation_key:
try:
# Parse the annotation key
# Example: "easyhaproxy.plugin.jwt_validator.k8s_secret.pubkey" -> "pubkey"
parts = annotation_key.split(".k8s_secret.")
if len(parts) != 2:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Malformed k8s_secret annotation: {annotation_key}"
)
continue
prefix = parts[0] # "easyhaproxy.plugin.jwt_validator"
config_key = parts[1] # "pubkey"
target_annotation = f"{prefix}.{config_key}" # "easyhaproxy.plugin.jwt_validator.pubkey"
# Parse secret_value: can be "secret_name" or "secret_name/key_name"
if "/" in secret_value:
secret_name, explicit_key_name = secret_value.split("/", 1)
use_explicit_key = True
else:
secret_name = secret_value
explicit_key_name = None
use_explicit_key = False
# Read the secret
secret = self.api_instance.read_namespaced_secret(
secret_name,
ingress.metadata.namespace
)
# Try to find the key in the secret data
secret_data = None
tried_keys = []
if use_explicit_key:
# User specified exact key name - only try that one
tried_keys = [explicit_key_name]
if explicit_key_name in secret.data:
secret_data = secret.data[explicit_key_name]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found explicit secret key '{explicit_key_name}' "
f"in secret '{secret_name}'"
)
else:
# No explicit key - try config_key and common variations
tried_keys = [config_key]
if config_key in secret.data:
secret_data = secret.data[config_key]
else:
# Try common variations for the requested key
variations = []
if config_key == "pubkey":
variations = ["public-key", "jwt.pub", "tls.crt"]
elif config_key == "password":
variations = ["pass", "pwd"]
elif config_key == "api_key":
variations = ["apikey", "api-key", "key"]
for variation in variations:
tried_keys.append(variation)
if variation in secret.data:
secret_data = secret.data[variation]
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Found secret key '{variation}' "
f"for requested key '{config_key}'"
)
break
if secret_data:
# Decode from base64 (Kubernetes secrets are base64-encoded)
# Then re-encode to base64 for plugin (plugin expects base64-encoded)
decoded = base64.b64decode(secret_data).decode('ascii')
reencoded = base64.b64encode(decoded.encode('ascii')).decode('ascii')
# Store the processed annotation
k8s_secret_annotations[target_annotation] = reencoded
logger_easyhaproxy.info(
f"Ingress {ingress_name} - Loaded '{config_key}' from secret "
f"'{secret_name}' for annotation '{target_annotation}'"
)
else:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Secret '{secret_name}' found but "
f"no matching key (tried: {', '.join(tried_keys)})"
)
except Exception as e:
logger_easyhaproxy.warn(
f"Ingress {ingress_name} - Failed to process k8s_secret annotation "
f"'{annotation_key}' with value '{secret_value}': {e}"
)
# Merge k8s_secret annotations into plugin_annotations
# k8s_secret annotations will NOT override existing explicit annotations (lower priority)
for key, value in k8s_secret_annotations.items():
if key not in plugin_annotations:
plugin_annotations[key] = value
else:
logger_easyhaproxy.debug(
f"Ingress {ingress_name} - Skipping k8s_secret annotation '{key}' "
f"because explicit annotation already exists"
)
data = {"creation_timestamp": ingress.metadata.creation_timestamp.strftime("%x %X"),
"resource_version": ingress.metadata.resource_version, "namespace": ingress.metadata.namespace}
if ingress.spec.tls is not None:
for tls in ingress.spec.tls:
try:
secret = self.api_instance.read_namespaced_secret(tls.secret_name, ingress.metadata.namespace)
if "tls.crt" not in secret.data or "tls.key" not in secret.data:
continue
if tls.secret_name not in self.cert_cache or self.cert_cache[tls.secret_name] != secret.data:
self.cert_cache[tls.secret_name] = secret.data
Functions.save(
f"{Consts.certs_haproxy}/{tls.secret_name}.pem",
base64.b64decode(secret.data["tls.crt"]).decode('ascii') + "\n" + base64.b64decode(
secret.data["tls.key"]).decode('ascii')
)
ssl_hosts.extend(tls.hosts)
except Exception as e:
logger_easyhaproxy.warn(f"Ingress {ingress_name} - Get secret failed: '{e}'")
logger_easyhaproxy.debug(f"Ingress {ingress_name} - SSL Hosts found '{ssl_hosts}'")
for rule in ingress.spec.rules:
rule_data = {}
port_number = rule.http.paths[0].backend.service.port.number
definition = f"easyhaproxy.{rule.host.replace('.', '-')}_{port_number}"
rule_data[f"{definition}.host"] = rule.host
rule_data[f"{definition}.port"] = listen_port
rule_data[f"{definition}.localport"] = port_number
if rule.host in ssl_hosts:
rule_data[f"{definition}.clone_to_ssl"] = 'true'
if redirect_ssl is not None:
rule_data[f"{definition}.redirect_ssl"] = redirect_ssl
if certbot is not None:
rule_data[f"{definition}.certbot"] = certbot
if redirect is not None:
rule_data[f"{definition}.redirect"] = redirect
if mode is not None:
rule_data[f"{definition}.mode"] = mode
rule_data[f"{definition}.balance"] = self._check_annotation(ingress.metadata.annotations, "easyhaproxy.balance", "roundrobin")
# Add plugin configuration
if plugins is not None:
rule_data[f"{definition}.plugins"] = plugins
# Add plugin-specific configurations
for plugin_key, plugin_value in plugin_annotations.items():
# Convert easyhaproxy.plugin.X.Y to easyhaproxy.{definition}.plugin.X.Y
plugin_config_key = plugin_key.replace("easyhaproxy.plugin.", f"{definition}.plugin.")
rule_data[plugin_config_key] = plugin_value
service_name = rule.http.paths[0].backend.service.name
try:
api_response = self.api_instance.read_namespaced_service(service_name, ingress.metadata.namespace)
cluster_ip = api_response.spec.cluster_ip
except ApiException as e:
cluster_ip = None
logger_easyhaproxy.warn(f"Ingress {ingress_name} - Service {service_name} - Failed: '{e}'")
if cluster_ip is not None:
if cluster_ip not in self.parsed_object.keys():
self.parsed_object[cluster_ip] = data
self.parsed_object[cluster_ip].update(rule_data)
# Update ingress status if enabled
if env_config['update_ingress_status'] and ingress_addresses:
self._update_ingress_status(ingress, ingress_addresses)

143
src/processor/static.py Normal file
View file

@ -0,0 +1,143 @@
import json
import yaml
from easymapping import HaproxyConfigGenerator
from functions import ContainerEnv, Functions
from .interface import ProcessorInterface
class Static(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.static_content = None
self.static_content = None
self.cfg = None
super().__init__(filename)
def inspect_network(self):
"""Load YAML and convert containers to Docker-style container metadata"""
# Load YAML
self.static_content = yaml.load(Functions.load(self.filename), Loader=yaml.FullLoader)
# Convert containers to label format
self.parsed_object = self._convert_yaml_to_labels()
def _convert_yaml_to_labels(self):
"""
Convert static YAML containers to Docker label format.
Returns: {IP: {labels}} structure that parse() can process
"""
container_metadata = {}
# Get global plugin configuration
global_plugins = self.static_content.get("plugins", {})
global_enabled = global_plugins.get("enabled", [])
global_plugin_config = global_plugins.get("config", {})
for host_port, config in self.static_content.get("containers", {}).items():
# Parse hostname:port from key
if ":" in host_port:
hostname, port = host_port.rsplit(":", 1)
else:
hostname = host_port
port = "80"
# Create definition: hostname_port (e.g., host1_com_br_80)
definition = hostname.replace(".", "_") + f"_{port}"
# Handle redirect-only entries (no backend)
if "redirect" in config and "ip" not in config:
# Create metadata with redirect but mark as redirect-only to skip backend creation
fake_ip = f"redirect-{hostname}-{port}"
if fake_ip not in container_metadata:
container_metadata[fake_ip] = {}
container_metadata[fake_ip].update({
f"easyhaproxy.{definition}.host": hostname,
f"easyhaproxy.{definition}.port": port,
f"easyhaproxy.{definition}.redirect": json.dumps({hostname: config["redirect"]}),
f"easyhaproxy.{definition}.redirect_only": "true", # Marker to skip backend
})
continue
# Get IPs/containers
ip_list = config.get("ip", [hostname])
# Process each container/IP
for container_spec in ip_list:
# Parse container:localport
if ":" in container_spec:
container_addr, localport = container_spec.rsplit(":", 1)
else:
container_addr = container_spec
localport = "80"
# Use container address as IP (could be IP, DNS, or container name)
ip = container_addr
# Build labels dict
labels = {
f"easyhaproxy.{definition}.host": hostname,
f"easyhaproxy.{definition}.port": port,
f"easyhaproxy.{definition}.localport": localport,
}
# Add optional settings
for key in ["mode", "certbot", "redirect_ssl", "ssl", "balance", "proto", "ssl-check", "clone_to_ssl"]:
if key in config:
value = config[key]
# Convert boolean to string
if isinstance(value, bool):
value = "true" if value else "false"
labels[f"easyhaproxy.{definition}.{key}"] = str(value)
# Handle plugins
host_plugins = config.get("plugins", global_enabled)
if host_plugins:
# Convert list to comma-separated string if needed
if isinstance(host_plugins, list):
plugins_str = ",".join(host_plugins)
else:
plugins_str = host_plugins
labels[f"easyhaproxy.{definition}.plugins"] = plugins_str
# Process plugin configurations
host_plugin_config = config.get("plugin", {})
# Parse plugins list
plugins_list = host_plugins if isinstance(host_plugins, list) else [p.strip() for p in host_plugins.split(",")]
for plugin_name in plugins_list:
# Merge global and host-specific config
merged_config = {}
if plugin_name in global_plugin_config:
merged_config.update(global_plugin_config[plugin_name])
if plugin_name in host_plugin_config:
merged_config.update(host_plugin_config[plugin_name])
# Convert plugin config to labels
for config_key, config_value in merged_config.items():
label_key = f"easyhaproxy.{definition}.plugin.{plugin_name}.{config_key}"
# Convert list values to comma-separated strings
if isinstance(config_value, list):
label_value = ",".join(str(v) for v in config_value)
else:
label_value = str(config_value)
labels[label_key] = label_value
# Initialize container entry if it doesn't exist
if ip not in container_metadata:
container_metadata[ip] = {}
# Merge labels instead of overwriting
container_metadata[ip].update(labels)
return container_metadata
def parse(self):
"""Create HaproxyConfigGenerator with YAML config merged into env vars"""
self.cfg = HaproxyConfigGenerator(ContainerEnv.read(self.static_content))

50
src/processor/swarm.py Normal file
View file

@ -0,0 +1,50 @@
import socket
import docker
from .interface import ProcessorInterface
class Swarm(ProcessorInterface):
def __init__(self, filename=None):
self.parsed_object = None
self.client = docker.from_env()
super().__init__()
def inspect_network(self):
ha_proxy_service_name = self.client.containers.get(socket.gethostname()).name.split('.')[0]
ha_proxy_network_id = None
swarm_ingress_id = None
# Get the HAProxy network and the ingress network
for endpoint in self.client.services.get(ha_proxy_service_name).attrs['Endpoint']["VirtualIPs"]:
network_name = self.client.networks.get(endpoint["NetworkID"]).name
if swarm_ingress_id is None and network_name == 'ingress':
swarm_ingress_id = endpoint["NetworkID"]
if ha_proxy_network_id is None and network_name != 'ingress':
ha_proxy_network_id = endpoint["NetworkID"]
if ha_proxy_network_id is not None and swarm_ingress_id is not None:
break
# Check if the service is attached to the HAProxy network
self.parsed_object = {}
for service in self.client.services.list():
if not any(self.label in key for key in service.attrs["Spec"]["Labels"]):
continue
ip_address = None
network_list = []
for endpoint in service.attrs["Endpoint"]["VirtualIPs"]:
if ha_proxy_network_id == endpoint["NetworkID"]:
ip_address = endpoint["Addr"].split("/")[0]
break
elif swarm_ingress_id != endpoint["NetworkID"]:
network_list.append(endpoint["NetworkID"])
# Attach the service to the HAProxy network
if ip_address is None:
network_list.append(ha_proxy_network_id)
service.update(networks=network_list)
continue # skip to the next service to give time to update the network
self.parsed_object[ip_address] = service.attrs["Spec"]["Labels"]

View file

@ -1,2 +0,0 @@
[pytest]
addopts = -v -p no:warnings

View file

@ -1,9 +0,0 @@
pyyaml
docker
jinja2
pytest
docker
kubernetes
deepdiff
pyopenssl
psutil

View file

@ -1,5 +1,5 @@
{% if "ssl" in o %} {% if "ssl" in o %}
bind *:{{ o["port"] }} ssl crt /certs/certbot/ alpn h2,http/1.1 crt /certs/haproxy/ alpn h2,http/1.1 bind *:{{ o["port"] }} ssl crt /etc/easyhaproxy/certs/certbot/ alpn h2,http/1.1 crt /etc/easyhaproxy/certs/haproxy/ alpn h2,http/1.1
{% elif "h2" in o and o["h2"] %} {% elif "h2" in o and o["h2"] %}
bind *:{{ o["port"] }} proto h2 bind *:{{ o["port"] }} proto h2
option http-use-htx option http-use-htx

View file

@ -25,19 +25,28 @@ global
defaults defaults
log global log global
unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
unique-id-header X-Edge-Request-ID
option httplog option httplog
timeout connect 3s timeout connect 3s
timeout client 10s timeout client 10s
timeout server 10m timeout server 10m
{% if data["customerrors"] %} {% if data["customerrors"] %}
errorfile 400 /etc/haproxy/errors-custom/400.http errorfile 400 /etc/easyhaproxy/haproxy/errors-custom/400.http
errorfile 403 /etc/haproxy/errors-custom/403.http errorfile 403 /etc/easyhaproxy/haproxy/errors-custom/403.http
errorfile 408 /etc/haproxy/errors-custom/408.http errorfile 408 /etc/easyhaproxy/haproxy/errors-custom/408.http
errorfile 500 /etc/haproxy/errors-custom/500.http errorfile 500 /etc/easyhaproxy/haproxy/errors-custom/500.http
errorfile 502 /etc/haproxy/errors-custom/502.http errorfile 502 /etc/easyhaproxy/haproxy/errors-custom/502.http
errorfile 503 /etc/haproxy/errors-custom/503.http errorfile 503 /etc/easyhaproxy/haproxy/errors-custom/503.http
errorfile 504 /etc/haproxy/errors-custom/504.http errorfile 504 /etc/easyhaproxy/haproxy/errors-custom/504.http
{% endif %}
{% if defaults_plugin_configs %}
# Defaults Plugin Configurations
{% for config in defaults_plugin_configs %}
{{ config }}
{% endfor %}
{% endif %} {% endif %}
{% if global_plugin_configs %} {% if global_plugin_configs %}
@ -53,6 +62,23 @@ frontend stats
bind *:{{ data_stats["port"] | default(1936) }} bind *:{{ data_stats["port"] | default(1936) }}
mode http mode http
http-request use-service prometheus-exporter if { path /metrics } http-request use-service prometheus-exporter if { path /metrics }
{%- if data_stats["cors_origin"] | default("") != "" %}
# CORS for stats dashboard (only for configured origin)
acl from_ui hdr(Origin) -i {{ data_stats["cors_origin"] }}
acl preflight method OPTIONS
# Preflight response
http-request return status 204 hdr "Access-Control-Allow-Origin" "%[req.hdr(Origin)]" hdr "Access-Control-Allow-Methods" "GET, OPTIONS" hdr "Access-Control-Allow-Headers" "Authorization, Content-Type" hdr "Access-Control-Max-Age" "86400" hdr "Vary" "Origin" if from_ui preflight
# Actual response headers
http-after-response set-header Access-Control-Allow-Origin "{{ data_stats["cors_origin"] }}"
http-after-response set-header Access-Control-Allow-Methods "GET, OPTIONS"
http-after-response set-header Access-Control-Allow-Headers "Authorization, Content-Type"
http-after-response set-header Access-Control-Expose-Headers "X-Request-ID"
http-after-response set-header Vary "Origin"
{% endif %}
stats enable stats enable
stats hide-version stats hide-version
stats realm Haproxy\ Statistics stats realm Haproxy\ Statistics
@ -92,6 +118,8 @@ backend srv_{{ host }}
option forwardfor option forwardfor
http-request set-header X-Forwarded-Port %[dst_port] http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc } http-request add-header X-Forwarded-Proto https if { ssl_fc }
http-request set-header X-Forwarded-Host %[req.hdr(Host)]
http-request set-header X-Request-ID %[uuid()]
{% elif mode == "tcp" %} {% elif mode == "tcp" %}
option tcp-check option tcp-check
tcp-check connect{{ " ssl" if o["ssl-check"] == "ssl" }} tcp-check connect{{ " ssl" if o["ssl-check"] == "ssl" }}

View file

@ -1,12 +1,10 @@
tune.ssl.default-dh-param 2048
# intermediate configuration # intermediate configuration
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets ssl-default-bind-options prefer-client-ciphers no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
ssl-dh-param-file /etc/haproxy/dhparam

View file

@ -1,10 +1,8 @@
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options no-sslv3 no-tls-tickets ssl-default-bind-options no-sslv3 no-tls-tickets
ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options no-sslv3 no-tls-tickets ssl-default-server-options no-sslv3 no-tls-tickets
ssl-dh-param-file /etc/haproxy/dhparam-1024

View file

@ -1,5 +0,0 @@
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

View file

@ -1,31 +0,0 @@
stats:
username: admin
password: test123
port: 1936 # Optional (default 1936)
customerrors: true # Optional (default false)
easymapping:
- port: 80
hosts:
host1.com.br:
containers:
- container:5000
certbot: true
host2.com.br:
containers:
- other:3000
redirect:
www.host1.com.br: http://host1.com.br
- port: 443
ssl: True
hosts:
host1.com.br:
containers:
- container:80
- port: 8080
hosts:
host3.com.br:
containers: [ "domain:8181" ]

Some files were not shown because too many files have changed in this diff Show more