Merge pull request #67 from byjg/ingressclassname
Add ingress status update and support for ingress class changes
This commit is contained in:
commit
deade0a928
205 changed files with 14071 additions and 2907 deletions
161
.github/workflows/build.yml
vendored
161
.github/workflows/build.yml
vendored
|
|
@ -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
27
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.12
|
||||||
|
|
@ -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="""" />
|
<option name="_new_keywords" value="""" />
|
||||||
<option name="_new_parameters" value="""" />
|
<option name="_new_parameters" value="""" />
|
||||||
<option name="_new_additionalArguments" value="""" />
|
<option name="_new_additionalArguments" value="""" />
|
||||||
<option name="_new_target" value=""$PROJECT_DIR$/src/tests"" />
|
<option name="_new_target" value=""$PROJECT_DIR$/tests"" />
|
||||||
<option name="_new_targetType" value=""PATH"" />
|
<option name="_new_targetType" value=""PATH"" />
|
||||||
<method v="2" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|
|
||||||
20
Makefile
20
Makefile
|
|
@ -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/
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -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)
|
||||||
[](https://youtu.be/B_bYZnRTGJM)
|
[](https://youtu.be/B_bYZnRTGJM)
|
||||||
[](https://youtu.be/JHqcq9crbDI)
|
[](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.
|
||||||
|
|
||||||
|
|
||||||
----
|
----
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" ]
|
|
||||||
|
|
@ -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-----
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Custom HAProxy
|
|
||||||
|
|
||||||
Put files .cfg to be included in the configuration
|
|
||||||
53
deploy/docker/Dockerfile
Normal file
53
deploy/docker/Dockerfile
Normal 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"]
|
||||||
13
deploy/docker/assets/entrypoint.sh
Executable file
13
deploy/docker/assets/entrypoint.sh
Executable 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 "$@"
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
91
deploy/kubernetes/README.md
Normal file
91
deploy/kubernetes/README.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,10 @@ 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:
|
||||||
|
|
@ -85,10 +85,10 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)(/.*)?$
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
161
docs/acme.md
161
docs/acme.md
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
sidebar_position: 8
|
sidebar_position: 9
|
||||||
---
|
---
|
||||||
|
|
||||||
# DigitalOcean
|
# DigitalOcean
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
sidebar_position: 7
|
sidebar_position: 8
|
||||||
---
|
---
|
||||||
|
|
||||||
# Dokku
|
# Dokku
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
---
|
---
|
||||||
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_BASE_PATH | (Optional) Base directory for all EasyHAProxy files. All paths (config, certs, plugins, www) are constructed relative to this base. | `/etc/easyhaproxy` |
|
||||||
| EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* |
|
| EASYHAPROXY_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* |
|
||||||
| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
|
| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
|
||||||
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
|
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
|
||||||
|
|
@ -17,6 +18,7 @@ sidebar_position: 12
|
||||||
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
|
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
|
||||||
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
|
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
|
||||||
| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` |
|
| HAPROXY_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` |
|
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` |
|
||||||
|
|
||||||
:::tip HAProxy Stats
|
:::tip HAProxy Stats
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
sidebar_position: 5
|
sidebar_position: 6
|
||||||
---
|
---
|
||||||
|
|
||||||
# Helm 3
|
# Helm 3
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
sidebar_position: 6
|
sidebar_position: 7
|
||||||
---
|
---
|
||||||
|
|
||||||
# Microk8s Add-ons
|
# Microk8s Add-ons
|
||||||
|
|
|
||||||
|
|
@ -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
211
docs/pip.md
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
134
docs/ssl.md
134
docs/ssl.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
- container:5000
|
|
||||||
certbot: true
|
certbot: true
|
||||||
redirect_ssl: 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 www → main domain
|
||||||
redirect_ssl: false
|
"www.host1.com.br:80":
|
||||||
|
ip: ["container:5000"]
|
||||||
|
redirect_ssl: true
|
||||||
|
|
||||||
|
# HTTPS version
|
||||||
|
"host1.com.br:443":
|
||||||
|
ip: ["container:80"]
|
||||||
ssl: true
|
ssl: true
|
||||||
|
|
||||||
- port: 8080
|
# Different host on different port
|
||||||
hosts:
|
"host3.com.br:8080":
|
||||||
host3.com.br:
|
ip: ["domain:8181"]
|
||||||
containers:
|
|
||||||
- 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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
194
docs/volumes.md
194
docs/volumes.md
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
14
helm/easyhaproxy/templates/ingressclass.yaml
Normal file
14
helm/easyhaproxy/templates/ingressclass.yaml
Normal 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 }}
|
||||||
|
|
@ -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
94
pyproject.toml
Normal 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__.:",
|
||||||
|
]
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
20
setup.py
20
setup.py
|
|
@ -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
234
src/easyhaproxy/main.py
Normal 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()
|
||||||
|
|
@ -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()
|
|
||||||
310
src/easymapping/config_generator.py
Normal file
310
src/easymapping/config_generator.py
Normal 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()
|
||||||
52
src/easymapping/label_handler.py
Normal file
52
src/easymapping/label_handler.py
Normal 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
|
||||||
|
|
@ -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
220
src/functions/certbot.py
Normal 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
59
src/functions/consts.py
Normal 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"
|
||||||
187
src/functions/container_env.py
Normal file
187
src/functions/container_env.py
Normal 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
31
src/functions/filter.py
Normal 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
|
||||||
88
src/functions/functions.py
Normal file
88
src/functions/functions.py
Normal 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
152
src/functions/haproxy.py
Normal 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
13
src/functions/loggers.py
Normal 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)
|
||||||
78
src/main.py
78
src/main.py
|
|
@ -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()
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
51
src/plugins/interface.py
Normal 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
216
src/plugins/manager.py
Normal 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
46
src/plugins/types.py
Normal 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
|
||||||
|
|
@ -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
35
src/processor/docker.py
Normal 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
|
||||||
87
src/processor/interface.py
Normal file
87
src/processor/interface.py
Normal 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
462
src/processor/kubernetes.py
Normal 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
143
src/processor/static.py
Normal 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
50
src/processor/swarm.py
Normal 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"]
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[pytest]
|
|
||||||
addopts = -v -p no:warnings
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
pyyaml
|
|
||||||
docker
|
|
||||||
jinja2
|
|
||||||
pytest
|
|
||||||
docker
|
|
||||||
kubernetes
|
|
||||||
deepdiff
|
|
||||||
pyopenssl
|
|
||||||
psutil
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" }}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
||||||
31
src/tests/fixtures/static.yml
vendored
31
src/tests/fixtures/static.yml
vendored
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue