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
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install requirements
|
||||
- name: Install uv
|
||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd src/
|
||||
pip install -r requirements.txt
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
uv sync --group dev
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd src/
|
||||
pytest -s tests/ -vv
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: Test
|
||||
needs: [Test, Tests-E2E-Docker, Tests-E2E-Kubernetes, Tests-E2E-Additional, Tests-E2E-Swarm]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
|
@ -64,7 +179,7 @@ jobs:
|
|||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- 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
|
||||
with:
|
||||
registry: ${{ secrets.DOCKER_REGISTRY }}
|
||||
|
|
@ -127,11 +242,11 @@ jobs:
|
|||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: build/Dockerfile
|
||||
file: deploy/docker/Dockerfile
|
||||
build-args: |
|
||||
RELEASE_VERSION_ARG="${{ steps.tags.outputs.result }}"
|
||||
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 }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
|
@ -140,12 +255,35 @@ jobs:
|
|||
|
||||
|
||||
# - 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: |
|
||||
# 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
|
||||
# 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:
|
||||
runs-on: 'ubuntu-latest'
|
||||
needs: Build
|
||||
|
|
@ -191,4 +329,5 @@ jobs:
|
|||
with:
|
||||
folder: devops
|
||||
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
|
||||
*.pyc
|
||||
.env
|
||||
/examples/static/conf/config.yml
|
||||
/examples/docker/certs/haproxy/.place_holder_cert.pem
|
||||
/examples/static/host1.local.pem
|
||||
/examples/swarm/certs/host1.local.pem
|
||||
/examples/docker/host2.local.pem
|
||||
/examples/swarm/certs/host2.local.pem
|
||||
/examples/docker/jwt_private.pem
|
||||
/examples/docker/jwt_pubkey.pem
|
||||
/examples/docker/cloudflare_ips.lst
|
||||
|
||||
# uv
|
||||
.venv/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
/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="PARENT_ENVS" value="true" />
|
||||
<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="IS_MODULE_SDK" value="false" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="RUN_TOOL" value="true" />
|
||||
<option name="_new_keywords" value="""" />
|
||||
<option name="_new_parameters" 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"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
|
|
|
|||
20
Makefile
20
Makefile
|
|
@ -2,8 +2,24 @@ VERSION := $(shell git rev-parse --short HEAD)
|
|||
|
||||
.PHONY: 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
|
||||
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.)
|
||||
- [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
|
||||
|
||||
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/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
|
||||
- **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)
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ The automated release process is triggered by pushing a semantic version tag.
|
|||
```bash
|
||||
make build
|
||||
# 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)
|
||||
|
|
@ -329,6 +329,7 @@ helm show chart byjg/easyhaproxy
|
|||
|
||||
| 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 |
|
||||
| 4.6.0 | 2024-11-27 | Minor | FastCGI plugin, JWT enhancements |
|
||||
| 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
|
||||
do
|
||||
DOCKERFILE=build/Dockerfile
|
||||
DOCKERFILE=deploy/docker/Dockerfile
|
||||
|
||||
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
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html>Ins
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>400 Bad request</title>
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
services:
|
||||
easyhaproxy:
|
||||
image: byjg/easy-haproxy:5.0.0
|
||||
image: byjg/easy-haproxy:6.0.0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- certs_certbot:/certs/certbot
|
||||
- certs_haproxy:/certs/haproxy
|
||||
|
||||
- certs_certbot:/etc/easyhaproxy/certs/certbot
|
||||
- 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:
|
||||
EASYHAPROXY_DISCOVER: docker
|
||||
EASYHAPROXY_LABEL_PREFIX: easyhaproxy
|
||||
|
|
@ -15,21 +20,19 @@ services:
|
|||
HAPROXY_USERNAME: admin
|
||||
HAPROXY_PASSWORD: password
|
||||
HAPROXY_STATS_PORT: 1936
|
||||
HAPROXY_STATS_CORS_ORIGIN: "http://localhost:8080"
|
||||
|
||||
ports:
|
||||
- "80:80/tcp"
|
||||
- "443:443/tcp"
|
||||
- "1936:1936/tcp"
|
||||
|
||||
networks:
|
||||
- easyhaproxy
|
||||
|
||||
volumes:
|
||||
certs_certbot:
|
||||
external: true
|
||||
# external: true
|
||||
certs_haproxy:
|
||||
external: true
|
||||
# external: true
|
||||
|
||||
networks:
|
||||
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
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
---
|
||||
# Source: easyhaproxy/templates/clusterrole.yaml
|
||||
|
|
@ -19,10 +19,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
rules:
|
||||
- apiGroups:
|
||||
|
|
@ -30,7 +30,7 @@ rules:
|
|||
resources:
|
||||
# - configmaps
|
||||
# - endpoints
|
||||
# - nodes
|
||||
- nodes
|
||||
- pods
|
||||
- services
|
||||
- namespaces
|
||||
|
|
@ -41,23 +41,21 @@ rules:
|
|||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- "extensions"
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses
|
||||
# - ingresses/status
|
||||
# - ingressclasses
|
||||
- ingresses/status
|
||||
- ingressclasses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
# - apiGroups:
|
||||
# - "extensions"
|
||||
# - "networking.k8s.io"
|
||||
# resources:
|
||||
# - ingresses/status
|
||||
# verbs:
|
||||
# - update
|
||||
- apiGroups:
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
|
|
@ -85,10 +83,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
|
@ -107,10 +105,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
annotations:
|
||||
{}
|
||||
|
|
@ -139,12 +137,13 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
|
|
@ -155,15 +154,6 @@ spec:
|
|||
app.kubernetes.io/name: easyhaproxy
|
||||
app.kubernetes.io/instance: ingress
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: easyhaproxy/node
|
||||
operator: In
|
||||
values:
|
||||
- master
|
||||
serviceAccountName: ingress-easyhaproxy
|
||||
securityContext:
|
||||
{}
|
||||
|
|
@ -171,7 +161,7 @@ spec:
|
|||
- name: easyhaproxy
|
||||
securityContext:
|
||||
{}
|
||||
image: "byjg/easy-haproxy:5.0.0"
|
||||
image: "byjg/easy-haproxy:6.0.0"
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
|
|
@ -184,9 +174,7 @@ spec:
|
|||
containerPort: 1936
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
{}
|
||||
env:
|
||||
- name: EASYHAPROXY_DISCOVER
|
||||
value: kubernetes
|
||||
|
|
@ -206,3 +194,27 @@ spec:
|
|||
value: DEBUG
|
||||
- name: CERTBOT_LOG_LEVEL
|
||||
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
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
---
|
||||
# Source: easyhaproxy/templates/clusterrole.yaml
|
||||
|
|
@ -19,10 +19,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
rules:
|
||||
- apiGroups:
|
||||
|
|
@ -30,7 +30,7 @@ rules:
|
|||
resources:
|
||||
# - configmaps
|
||||
# - endpoints
|
||||
# - nodes
|
||||
- nodes
|
||||
- pods
|
||||
- services
|
||||
- namespaces
|
||||
|
|
@ -41,23 +41,21 @@ rules:
|
|||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- "extensions"
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses
|
||||
# - ingresses/status
|
||||
# - ingressclasses
|
||||
- ingresses/status
|
||||
- ingressclasses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
# - apiGroups:
|
||||
# - "extensions"
|
||||
# - "networking.k8s.io"
|
||||
# resources:
|
||||
# - ingresses/status
|
||||
# verbs:
|
||||
# - update
|
||||
- apiGroups:
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
|
|
@ -85,10 +83,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
|
@ -106,10 +104,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
spec:
|
||||
selector:
|
||||
|
|
@ -138,7 +136,7 @@ spec:
|
|||
- name: easyhaproxy
|
||||
securityContext:
|
||||
{}
|
||||
image: "byjg/easy-haproxy:5.0.0"
|
||||
image: "byjg/easy-haproxy:6.0.0"
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
|
|
@ -151,9 +149,7 @@ spec:
|
|||
containerPort: 1936
|
||||
hostPort: 1936
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
{}
|
||||
env:
|
||||
- name: EASYHAPROXY_DISCOVER
|
||||
value: kubernetes
|
||||
|
|
@ -173,3 +169,27 @@ spec:
|
|||
value: DEBUG
|
||||
- name: CERTBOT_LOG_LEVEL
|
||||
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
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
---
|
||||
# Source: easyhaproxy/templates/clusterrole.yaml
|
||||
|
|
@ -19,10 +19,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
rules:
|
||||
- apiGroups:
|
||||
|
|
@ -30,7 +30,7 @@ rules:
|
|||
resources:
|
||||
# - configmaps
|
||||
# - endpoints
|
||||
# - nodes
|
||||
- nodes
|
||||
- pods
|
||||
- services
|
||||
- namespaces
|
||||
|
|
@ -41,23 +41,21 @@ rules:
|
|||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- "extensions"
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses
|
||||
# - ingresses/status
|
||||
# - ingressclasses
|
||||
- ingresses/status
|
||||
- ingressclasses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
# - apiGroups:
|
||||
# - "extensions"
|
||||
# - "networking.k8s.io"
|
||||
# resources:
|
||||
# - ingresses/status
|
||||
# verbs:
|
||||
# - update
|
||||
- apiGroups:
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
|
|
@ -85,10 +83,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
|
@ -107,10 +105,10 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
annotations:
|
||||
{}
|
||||
|
|
@ -119,13 +117,13 @@ spec:
|
|||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
nodePort: 31080
|
||||
nodePort: 80
|
||||
- name: https
|
||||
port: 443
|
||||
nodePort: 31443
|
||||
nodePort: 443
|
||||
- name: stats
|
||||
port: 1936
|
||||
nodePort: 31936
|
||||
nodePort: 1936
|
||||
|
||||
|
||||
selector:
|
||||
|
|
@ -139,12 +137,13 @@ metadata:
|
|||
name: ingress-easyhaproxy
|
||||
namespace: easyhaproxy
|
||||
labels:
|
||||
helm.sh/chart: easyhaproxy-1.0.1
|
||||
helm.sh/chart: easyhaproxy-2.0.0
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
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
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: easyhaproxy
|
||||
|
|
@ -155,15 +154,6 @@ spec:
|
|||
app.kubernetes.io/name: easyhaproxy
|
||||
app.kubernetes.io/instance: ingress
|
||||
spec:
|
||||
affinity:
|
||||
nodeAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
nodeSelectorTerms:
|
||||
- matchExpressions:
|
||||
- key: easyhaproxy/node
|
||||
operator: In
|
||||
values:
|
||||
- master
|
||||
serviceAccountName: ingress-easyhaproxy
|
||||
securityContext:
|
||||
{}
|
||||
|
|
@ -171,7 +161,7 @@ spec:
|
|||
- name: easyhaproxy
|
||||
securityContext:
|
||||
{}
|
||||
image: "byjg/easy-haproxy:5.0.0"
|
||||
image: "byjg/easy-haproxy:6.0.0"
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
|
|
@ -184,9 +174,7 @@ spec:
|
|||
containerPort: 1936
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
{}
|
||||
env:
|
||||
- name: EASYHAPROXY_DISCOVER
|
||||
value: kubernetes
|
||||
|
|
@ -206,3 +194,27 @@ spec:
|
|||
value: DEBUG
|
||||
- name: CERTBOT_LOG_LEVEL
|
||||
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
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
plugins:
|
||||
enabled: [cleanup]
|
||||
config:
|
||||
|
|
@ -53,7 +53,7 @@ Configure the Cleanup plugin globally:
|
|||
### Custom Idle Time (1 hour)
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
plugins:
|
||||
enabled: [cleanup]
|
||||
config:
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ Cloudflare replaces the visitor's IP with its own. This plugin restores the orig
|
|||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Description | Default |
|
||||
|-------------------|------------------------------------------|-----------------------------------|
|
||||
| `enabled` | Enable/disable plugin | `true` |
|
||||
| `use_builtin_ips` | Use built-in Cloudflare IP ranges | `true` |
|
||||
| `ip_list_path` | Path to Cloudflare IP list | `/etc/haproxy/cloudflare_ips.lst` |
|
||||
| Option | Description | Default |
|
||||
|-------------------|------------------------------------------|---------------------------------------|
|
||||
| `enabled` | Enable/disable plugin | `true` |
|
||||
| `use_builtin_ips` | Use built-in Cloudflare IP ranges | `true` |
|
||||
| `ip_list_path` | Path to Cloudflare IP list | `/etc/easyhaproxy/cloudflare_ips.lst` |
|
||||
|
||||
## Configuration Examples
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ kind: Ingress
|
|||
metadata:
|
||||
annotations:
|
||||
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:
|
||||
rules:
|
||||
- host: example.com
|
||||
|
|
@ -72,7 +72,7 @@ spec:
|
|||
### Static YAML Configuration
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
plugins:
|
||||
config:
|
||||
cloudflare:
|
||||
|
|
@ -84,11 +84,11 @@ plugins:
|
|||
|
||||
Configure Cloudflare plugin defaults for all domains:
|
||||
|
||||
| Environment Variable | Config Key | Type | Default | Description |
|
||||
|-------------------------------------------------|-------------------|----------|-----------------------------------|---------------------------------------|
|
||||
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains |
|
||||
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS` | `use_builtin_ips` | boolean | `true` | Use built-in Cloudflare IP ranges |
|
||||
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH` | `ip_list_path` | string | `/etc/haproxy/cloudflare_ips.lst` | Path to Cloudflare IP list file |
|
||||
| Environment Variable | Config Key | Type | Default | Description |
|
||||
|-------------------------------------------------|-------------------|----------|---------------------------------------|---------------------------------------|
|
||||
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains |
|
||||
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_USE_BUILTIN_IPS` | `use_builtin_ips` | boolean | `true` | Use built-in Cloudflare IP ranges |
|
||||
| `EASYHAPROXY_PLUGIN_CLOUDFLARE_IP_LIST_PATH` | `ip_list_path` | string | `/etc/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.
|
||||
|
||||
|
|
@ -96,7 +96,7 @@ Configure Cloudflare plugin defaults for all domains:
|
|||
|
||||
```haproxy
|
||||
# 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
|
||||
```
|
||||
|
||||
|
|
@ -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
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -73,16 +73,14 @@ spec:
|
|||
### Static YAML Configuration
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
easymapping:
|
||||
- host: example.com
|
||||
port: 80
|
||||
container: webapp:80
|
||||
plugins:
|
||||
- deny_pages
|
||||
plugin_config:
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
containers:
|
||||
"example.com:80":
|
||||
ip: ["webapp:80"]
|
||||
plugins: [deny_pages]
|
||||
plugin:
|
||||
deny_pages:
|
||||
paths: /admin,/private,/debug
|
||||
paths: [/admin, /private, /debug]
|
||||
status_code: 403
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ Automatically generates HAProxy `fcgi-app` configuration that defines required C
|
|||
| Option | Description | Default |
|
||||
|-------------------|-----------------------------------------|------------------------------------|
|
||||
| `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) |
|
||||
| `index_file` | Default index file | `index.php` |
|
||||
| `path_info` | Enable PATH_INFO support | `true` |
|
||||
|
|
@ -40,10 +40,10 @@ services:
|
|||
easyhaproxy.http.localport: 9000
|
||||
easyhaproxy.http.proto: fcgi
|
||||
easyhaproxy.http.plugins: fastcgi
|
||||
easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html
|
||||
easyhaproxy.http.plugin.fastcgi.document_root: /etc/easyhaproxy/www
|
||||
easyhaproxy.http.plugin.fastcgi.index_file: index.php
|
||||
volumes:
|
||||
- ./app:/var/www/html
|
||||
- ./app:/etc/easyhaproxy/www
|
||||
```
|
||||
|
||||
### Docker/Docker Compose (Unix socket)
|
||||
|
|
@ -57,10 +57,10 @@ services:
|
|||
easyhaproxy.http.socket: /run/php/php-fpm.sock
|
||||
easyhaproxy.http.proto: fcgi
|
||||
easyhaproxy.http.plugins: fastcgi
|
||||
easyhaproxy.http.plugin.fastcgi.document_root: /var/www/html
|
||||
easyhaproxy.http.plugin.fastcgi.document_root: /etc/easyhaproxy/www
|
||||
easyhaproxy.http.plugin.fastcgi.index_file: index.php
|
||||
volumes:
|
||||
- ./app:/var/www/html
|
||||
- ./app:/etc/easyhaproxy/www
|
||||
- /run/php:/run/php
|
||||
```
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ kind: Ingress
|
|||
metadata:
|
||||
annotations:
|
||||
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"
|
||||
spec:
|
||||
rules:
|
||||
|
|
@ -100,7 +100,7 @@ spec:
|
|||
### Static YAML Configuration
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
easymapping:
|
||||
- host: phpapp.local
|
||||
port: 80
|
||||
|
|
@ -110,7 +110,7 @@ easymapping:
|
|||
- fastcgi
|
||||
plugin_config:
|
||||
fastcgi:
|
||||
document_root: /var/www/html
|
||||
document_root: /etc/easyhaproxy/www
|
||||
index_file: index.php
|
||||
path_info: true
|
||||
```
|
||||
|
|
@ -122,7 +122,7 @@ Configure FastCGI plugin defaults for all domains:
|
|||
| Environment Variable | Config Key | Type | Default | Description |
|
||||
|----------------------------------------------|-------------------|----------|-----------------|---------------------------------------|
|
||||
| `EASYHAPROXY_PLUGIN_FASTCGI_ENABLED` | `enabled` | boolean | `true` | Enable/disable plugin for all domains |
|
||||
| `EASYHAPROXY_PLUGIN_FASTCGI_DOCUMENT_ROOT` | `document_root` | string | `/var/www/html` | Document root path |
|
||||
| `EASYHAPROXY_PLUGIN_FASTCGI_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_INDEX_FILE` | `index_file` | string | `index.php` | Default index file |
|
||||
| `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
|
||||
# Top-level fcgi-app definition (added after defaults, before frontends/backends)
|
||||
fcgi-app fcgi_phpapp_local
|
||||
docroot /var/www/html
|
||||
docroot /etc/easyhaproxy/www
|
||||
index index.php
|
||||
path-info ^(/.+\.php)(/.*)?$
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ spec:
|
|||
### Static YAML Configuration
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
easymapping:
|
||||
- host: admin.example.com
|
||||
port: 443
|
||||
|
|
|
|||
|
|
@ -31,12 +31,22 @@ Protect APIs and services with JWT authentication without needing application-le
|
|||
| `algorithm` | JWT signing algorithm | `RS256` |
|
||||
| `issuer` | Expected JWT issuer (optional, set to `none`/`null` to skip validation) | (optional) |
|
||||
| `audience` | Expected JWT audience (optional, set to `none`/`null` to skip validation) | (optional) |
|
||||
| `pubkey_path` | Path to public key file (required if `pubkey` not provided) | (required) |
|
||||
| `pubkey` | Public key content as base64-encoded string (required if `pubkey_path` not provided) | (optional) |
|
||||
| `pubkey_path` | Path to public key file (priority 1: explicit file path) | (optional) |
|
||||
| `pubkey` | Public key content as base64-encoded string (priority 2: inline content) | (optional) |
|
||||
| `k8s_secret.pubkey` | Kubernetes secret containing public key (priority 3: Kubernetes only - see below) | (optional) |
|
||||
| `paths` | List of paths that require JWT validation (optional) | (all paths) |
|
||||
| `only_paths` | If `true`, only specified paths are accessible; if `false`, only specified paths require JWT | `false` |
|
||||
| `allow_anonymous` | If `true`, allows requests without Authorization header (validates JWT if present) | `false` |
|
||||
|
||||
### Public Key Configuration Priority
|
||||
|
||||
When multiple public key options are configured, they are evaluated in this order:
|
||||
1. **`pubkey_path`** - Direct file path (explicit configuration)
|
||||
2. **`pubkey`** - Base64-encoded key content (inline configuration)
|
||||
3. **`k8s_secret.pubkey`** - Kubernetes secret (recommended for Kubernetes deployments)
|
||||
|
||||
The first configured option is used; others are ignored.
|
||||
|
||||
## Path Validation Logic
|
||||
|
||||
- **No paths configured:** ALL requests to the domain require JWT validation (default behavior)
|
||||
|
|
@ -67,9 +77,9 @@ services:
|
|||
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
|
||||
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/
|
||||
easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
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
|
||||
|
|
@ -77,7 +87,7 @@ services:
|
|||
```yaml
|
||||
labels:
|
||||
easyhaproxy.http.plugins: jwt_validator
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
|
||||
easyhaproxy.http.plugin.jwt_validator.only_paths: false
|
||||
# /api/health, /api/docs, etc. remain publicly accessible
|
||||
|
|
@ -88,7 +98,7 @@ labels:
|
|||
```yaml
|
||||
labels:
|
||||
easyhaproxy.http.plugins: jwt_validator
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.paths: /api/public,/api/v1
|
||||
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||
# All paths except /api/public and /api/v1 are denied
|
||||
|
|
@ -100,7 +110,7 @@ labels:
|
|||
labels:
|
||||
easyhaproxy.http.plugin.jwt_validator.issuer: none
|
||||
easyhaproxy.http.plugin.jwt_validator.audience: none
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
```
|
||||
|
||||
### Allow Anonymous Access (Optional JWT)
|
||||
|
|
@ -111,16 +121,79 @@ services:
|
|||
labels:
|
||||
easyhaproxy.http.host: api.example.com
|
||||
easyhaproxy.http.plugins: jwt_validator
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.allow_anonymous: true
|
||||
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 with Authorization header are validated
|
||||
# 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
|
||||
apiVersion: networking.k8s.io/v1
|
||||
|
|
@ -131,15 +204,17 @@ metadata:
|
|||
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
|
||||
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
|
||||
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
|
||||
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem"
|
||||
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/easyhaproxy/jwt_keys/api_pubkey.pem"
|
||||
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
|
||||
easyhaproxy.plugin.jwt_validator.only_paths: "false"
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
rules:
|
||||
- host: api.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: api-service
|
||||
|
|
@ -147,22 +222,23 @@ spec:
|
|||
number: 8080
|
||||
```
|
||||
|
||||
**Note:** This requires mounting the public key file into the EasyHAProxy pod using ConfigMaps or volumes. Using `k8s_secret.pubkey` is recommended as it's simpler and more secure.
|
||||
|
||||
### Static YAML Configuration
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
easymapping:
|
||||
- host: api.example.com
|
||||
port: 443
|
||||
container: api-service:8080
|
||||
plugins:
|
||||
- jwt_validator
|
||||
plugin_config:
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
containers:
|
||||
"api.example.com:443":
|
||||
ip: ["api-service:8080"]
|
||||
ssl: true
|
||||
plugins: [jwt_validator]
|
||||
plugin:
|
||||
jwt_validator:
|
||||
algorithm: RS256
|
||||
issuer: https://auth.example.com/
|
||||
audience: https://api.example.com
|
||||
pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
```
|
||||
|
||||
### 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 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/easyhaproxy/jwt_keys/api_pubkey.pem") -m int 1 }
|
||||
|
||||
# Validate expiration
|
||||
http-request set-var(txn.now) date()
|
||||
|
|
@ -227,7 +303,7 @@ http-request set-var(txn.exp) http_auth_bearer,jwt_payload_query('$.exp','int')
|
|||
|
||||
# Validate JWT (only on protected paths)
|
||||
http-request deny content-type 'text/html' string 'Unsupported JWT signing algorithm' unless { var(txn.alg) -m str RS256 } if jwt_protected_path
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/easyhaproxy/jwt_keys/api_pubkey.pem") -m int 1 } if jwt_protected_path
|
||||
|
||||
# Validate expiration
|
||||
http-request set-var(txn.now) date() if jwt_protected_path
|
||||
|
|
@ -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)
|
||||
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
|
||||
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 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ } if { req.hdr(authorization) -m found }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com } if { req.hdr(authorization) -m found }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 } if { req.hdr(authorization) -m found }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/easyhaproxy/jwt_keys/api_pubkey.pem") -m int 1 } if { req.hdr(authorization) -m found }
|
||||
|
||||
# Validate expiration (only if Authorization header is present)
|
||||
http-request set-var(txn.now) date() if { req.hdr(authorization) -m found }
|
||||
|
|
|
|||
161
docs/acme.md
161
docs/acme.md
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 10
|
||||
sidebar_position: 11
|
||||
---
|
||||
|
||||
# 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).
|
||||
- Always set your contact email via `EASYHAPROXY_CERTBOT_EMAIL`.
|
||||
- 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.
|
||||
|
||||
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
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
Tips
|
||||
|
|
@ -54,19 +54,21 @@ Tips
|
|||
|
||||
## 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 |
|
||||
|------------------------------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| EASYHAPROXY_CERTBOT_EMAIL | YES | Your email in the certificate authority. |
|
||||
| EASYHAPROXY_CERTBOT_AUTOCONFIG | - | Will use pre-sets for your Certificate Authority (CA). See table below. |
|
||||
| EASYHAPROXY_CERTBOT_SERVER | - | The ACME Endpoint of your certificate authority. If you use AUTOCONFIG, it is set automatically. See table below. |
|
||||
| EASYHAPROXY_CERTBOT_EMAIL | **YES** | Your email for the certificate authority. Required for certificate issuance. |
|
||||
| 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 | **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_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_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) |
|
||||
|
||||
**\*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)
|
||||
|
||||
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 \
|
||||
-p 80:80 \
|
||||
-p 443:443 \
|
||||
-v /path/to/guest/certbot/certs:/certs/certbot \
|
||||
-v /path/to/guest/certbot/certs:/etc/easyhaproxy/certs/certbot \
|
||||
... \
|
||||
byjg/easy-haproxy
|
||||
```
|
||||
|
|
@ -118,7 +120,7 @@ docker run \
|
|||
|
||||
:::danger Important: Persist Certbot Certificates
|
||||
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
|
||||
- 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.
|
||||
:::
|
||||
|
||||
## 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)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 11
|
||||
sidebar_position: 12
|
||||
---
|
||||
|
||||
# Container Labels
|
||||
|
|
@ -134,6 +134,8 @@ backend srv_phpapp_local_80
|
|||
option forwardfor
|
||||
http-request set-header X-Forwarded-Port %[dst_port]
|
||||
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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 8
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
# DigitalOcean
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 7
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
# Dokku
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
---
|
||||
sidebar_position: 12
|
||||
sidebar_position: 13
|
||||
---
|
||||
|
||||
# Docker environment variables
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|--------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|
|
||||
| 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_CERTBOT_* | (Optional) Enable Let's Encrypt or any other ACME certificate. See more: [acme](acme.md) | *empty* |
|
||||
| EASYHAPROXY_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
|
||||
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
|
||||
| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
|
||||
| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
|
||||
| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO |
|
||||
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
|
||||
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
|
||||
| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` |
|
||||
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` |
|
||||
| Environment Variable | Description | Default |
|
||||
|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------|
|
||||
| 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_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_SSL_MODE | (Optional) `strict` supports only the most recent TLS version; `default` good SSL integration with recent browsers; `loose` supports all old SSL protocols for old browsers (not recommended). | `default` |
|
||||
| EASYHAPROXY_REFRESH_CONF | (Optional) Check for new containers/services every N seconds. | 10 |
|
||||
| EASYHAPROXY_LOG_LEVEL | (Optional) The log level for EasyHAproxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
|
||||
| CERTBOT_LOG_LEVEL | (Optional) The log level for Certbot messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | DEBUG |
|
||||
| HAPROXY_LOG_LEVEL | (Optional) The log level for HAProxy messages. Available: TRACE,DEBUG,INFO,WARN,ERROR,FATAL | INFO |
|
||||
| HAPROXY_USERNAME | (Optional) The HAProxy username for the statistics endpoint (used only when `HAPROXY_PASSWORD` is set). | `admin` |
|
||||
| HAPROXY_PASSWORD | (Optional) The HAProxy password to the statistics endpoint. Stats are **disabled** unless this is defined. | *empty* |
|
||||
| HAPROXY_STATS_PORT | (Optional) The HAProxy port to the statistics. If set to `false`, disable statistics. Only applies when `HAPROXY_PASSWORD` is defined. | `1936` |
|
||||
| HAPROXY_STATS_CORS_ORIGIN | (Optional) Enable CORS for the HAProxy stats dashboard by specifying the allowed origin (e.g., `http://localhost:3000`). Only applies when `HAPROXY_PASSWORD` is defined. | *empty* |
|
||||
| HAPROXY_CUSTOMERRORS | (Optional) If HAProxy will use custom HTML errors. true/false. | `false` |
|
||||
|
||||
:::tip HAProxy Stats
|
||||
Statistics are only configured when `HAPROXY_PASSWORD` is set. Without a password, the stats section is not generated.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 5
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Helm 3
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ sidebar_position: 1
|
|||
## Setup Kubernetes EasyHAProxy
|
||||
|
||||
:::info How it works
|
||||
EasyHAProxy for Kubernetes operates by querying all ingress definitions with the annotation
|
||||
`kubernetes.io/ingress.class: easyhaproxy-ingress`. Upon finding this annotation,
|
||||
EasyHAProxy immediately sets up HAProxy and begins serving traffic.
|
||||
EasyHAProxy for Kubernetes operates by querying all ingress definitions with either the
|
||||
`spec.ingressClassName: easyhaproxy` field (recommended) or the deprecated annotation
|
||||
`kubernetes.io/ingress.class: easyhaproxy-ingress` (for backward compatibility). Upon finding
|
||||
a matching ingress class, EasyHAProxy immediately sets up HAProxy and begins serving traffic.
|
||||
:::
|
||||
|
||||
For Kubernetes installations, there are three available installation modes:
|
||||
|
|
@ -47,25 +48,25 @@ kubectl label nodes node-01 "easyhaproxy/node=master"
|
|||
kubectl create namespace easyhaproxy
|
||||
|
||||
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)
|
||||
|
||||
## 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.
|
||||
|
||||
```yaml
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
name: example-ingress
|
||||
namespace: example
|
||||
spec:
|
||||
# Use ingressClassName (recommended)
|
||||
ingressClassName: easyhaproxy
|
||||
rules:
|
||||
- host: example.org
|
||||
http:
|
||||
|
|
@ -78,6 +79,10 @@ spec:
|
|||
pathType: ImplementationSpecific
|
||||
```
|
||||
|
||||
:::note Backward Compatibility
|
||||
The deprecated annotation `kubernetes.io/ingress.class: easyhaproxy-ingress` is still supported for backward compatibility, but `spec.ingressClassName` is the recommended approach for new deployments.
|
||||
:::
|
||||
|
||||
Once the container is running, EasyHAProxy will detect automatically and start to redirect all traffic from `example.org:80` to your container at port 8080.
|
||||
|
||||
You don't need to expose any port in your container.
|
||||
|
|
@ -92,7 +97,7 @@ You don't need to expose any port in your container.
|
|||
|
||||
| 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.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"} |
|
||||
|
|
@ -116,11 +121,11 @@ apiVersion: networking.k8s.io/v1
|
|||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.plugins: "cloudflare,deny_pages"
|
||||
name: example-ingress
|
||||
namespace: example
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
rules:
|
||||
- host: example.org
|
||||
http:
|
||||
|
|
@ -142,13 +147,13 @@ apiVersion: networking.k8s.io/v1
|
|||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.plugins: "deny_pages"
|
||||
easyhaproxy.plugin.deny_pages.paths: "/admin,/private,/config"
|
||||
easyhaproxy.plugin.deny_pages.status_code: "403"
|
||||
name: secure-app-ingress
|
||||
namespace: production
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
rules:
|
||||
- host: myapp.example.com
|
||||
http:
|
||||
|
|
@ -168,12 +173,13 @@ spec:
|
|||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.plugins: "jwt_validator"
|
||||
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
|
||||
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
|
||||
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
|
||||
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem"
|
||||
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.
|
||||
|
|
@ -183,10 +189,11 @@ metadata:
|
|||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.plugins: "ip_whitelist"
|
||||
easyhaproxy.plugin.ip_whitelist.allowed_ips: "192.168.1.0/24,10.0.0.5"
|
||||
easyhaproxy.plugin.ip_whitelist.status_code: "403"
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
```
|
||||
|
||||
**Restore Cloudflare visitor IPs:**
|
||||
|
|
@ -194,8 +201,9 @@ metadata:
|
|||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.plugins: "cloudflare"
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
```
|
||||
|
||||
**Multiple plugins together:**
|
||||
|
|
@ -203,10 +211,11 @@ metadata:
|
|||
```yaml
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.plugins: "cloudflare,deny_pages"
|
||||
easyhaproxy.plugin.deny_pages.paths: "/wp-admin,/wp-login.php"
|
||||
easyhaproxy.plugin.deny_pages.status_code: "404"
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
```
|
||||
|
||||
### Global Plugin Configuration
|
||||
|
|
@ -236,19 +245,232 @@ env:
|
|||
|
||||
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
|
||||
|
||||
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
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
easyhaproxy.certbot: 'true'
|
||||
name: example-ingress
|
||||
namespace: example
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
....
|
||||
```
|
||||
|
||||
|
|
@ -276,11 +498,10 @@ type: kubernetes.io/tls
|
|||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: easyhaproxy-ingress
|
||||
name: tls-example
|
||||
namespace: default
|
||||
spec:
|
||||
ingressClassName: easyhaproxy
|
||||
tls:
|
||||
- hosts:
|
||||
- host2.local
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 6
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# Microk8s Add-ons
|
||||
|
|
|
|||
|
|
@ -4,6 +4,26 @@ sidebar_position: 22
|
|||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
docker run \
|
||||
/* 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
|
||||
```
|
||||
|
||||
## 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`)
|
||||
|
||||
----
|
||||
|
|
|
|||
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
|
||||
|
|
@ -101,7 +101,13 @@ Execute **once per discovered domain/host**.
|
|||
├─ Calls plugin.configure(config) for each plugin
|
||||
└─ 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
|
||||
│ └─ Executes all global plugins once
|
||||
│
|
||||
|
|
@ -109,9 +115,11 @@ Execute **once per discovered domain/host**.
|
|||
└─ For each discovered domain:
|
||||
└─ Executes all domain plugins
|
||||
|
||||
4. RESULT PROCESSING
|
||||
5. RESULT PROCESSING
|
||||
├─ 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
|
||||
└─ Logs metadata for debugging
|
||||
```
|
||||
|
|
@ -119,7 +127,7 @@ Execute **once per discovered domain/host**.
|
|||
### Plugin Loading Order
|
||||
|
||||
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`).
|
||||
|
||||
|
|
@ -152,10 +160,10 @@ HAProxy Reload
|
|||
|
||||
### 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
|
||||
# /etc/haproxy/plugins/my_plugin.py
|
||||
# /etc/easyhaproxy/plugins/my_plugin.py
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -164,7 +172,7 @@ import sys
|
|||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import logger_easyhaproxy
|
||||
|
||||
|
||||
class MyPlugin(PluginInterface):
|
||||
|
|
@ -240,7 +248,7 @@ services:
|
|||
**Via YAML configuration:**
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
plugins:
|
||||
enabled: [my_plugin]
|
||||
config:
|
||||
|
|
@ -306,6 +314,18 @@ class PluginInterface(ABC):
|
|||
"""
|
||||
pass
|
||||
|
||||
def initialize(self) -> InitializationResult:
|
||||
"""
|
||||
Initialize plugin resources (new in v2.0)
|
||||
|
||||
Optional method to request file system resources.
|
||||
Default implementation returns empty result (no-op).
|
||||
|
||||
Returns:
|
||||
InitializationResult with resource requests
|
||||
"""
|
||||
return InitializationResult()
|
||||
|
||||
@abstractmethod
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
"""
|
||||
|
|
@ -328,6 +348,7 @@ class PluginInterface(ABC):
|
|||
**Methods:**
|
||||
|
||||
- `configure(config)` - Receives plugin configuration during initialization
|
||||
- `initialize()` - **[New in v2.0]** Request file system resources (optional)
|
||||
- `process(context)` - Main execution logic, returns `PluginResult`
|
||||
|
||||
### PluginType
|
||||
|
|
@ -390,6 +411,71 @@ def process(self, context: PluginContext) -> PluginResult:
|
|||
custom_label = context.host_config.get("custom_label", "default")
|
||||
```
|
||||
|
||||
### ResourceRequest
|
||||
|
||||
**[New in v2.0]** Request for file system resources during plugin initialization.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class ResourceRequest:
|
||||
"""Request for file system resources"""
|
||||
resource_type: str # "directory" or "file"
|
||||
path: str
|
||||
content: str | None = None
|
||||
overwrite: bool = False
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `resource_type` - Type of resource: `"directory"` or `"file"`
|
||||
- `path` - Absolute path to create
|
||||
- `content` - File content (only for `resource_type="file"`)
|
||||
- `overwrite` - Whether to overwrite existing files (default: False)
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
ResourceRequest(
|
||||
resource_type="directory",
|
||||
path="/etc/easyhaproxy/plugin_data"
|
||||
)
|
||||
|
||||
ResourceRequest(
|
||||
resource_type="file",
|
||||
path="/etc/easyhaproxy/plugin_config.txt",
|
||||
content="config data",
|
||||
overwrite=True
|
||||
)
|
||||
```
|
||||
|
||||
### InitializationResult
|
||||
|
||||
**[New in v2.0]** Plugin initialization result with resource requests.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class InitializationResult:
|
||||
"""Plugin initialization result with resource requests"""
|
||||
resources: list[ResourceRequest] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- `resources` - List of ResourceRequest objects
|
||||
- `metadata` - Optional metadata about initialization
|
||||
|
||||
**Example:**
|
||||
|
||||
```python
|
||||
def initialize(self) -> InitializationResult:
|
||||
return InitializationResult(
|
||||
resources=[
|
||||
ResourceRequest(resource_type="directory", path="/etc/easyhaproxy/jwt_keys")
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### PluginResult
|
||||
|
||||
Plugin execution result containing configuration and metadata.
|
||||
|
|
@ -401,6 +487,8 @@ class PluginResult:
|
|||
haproxy_config: str = "" # HAProxy config snippet to inject
|
||||
modified_easymapping: Optional[list] = None # Modified easymapping structure
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging
|
||||
global_configs: list[str] = field(default_factory=list) # [New] Global-level configs
|
||||
defaults_configs: list[str] = field(default_factory=list) # [New] Defaults-level configs
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
|
@ -408,11 +496,13 @@ class PluginResult:
|
|||
- `haproxy_config` - HAProxy configuration snippet (injected into backend/frontend)
|
||||
- `modified_easymapping` - Modified easymapping structure (optional, advanced use)
|
||||
- `metadata` - Dictionary with debugging/logging information
|
||||
- `global_configs` - **[New in v2.0]** List of global-level HAProxy configs (e.g., fcgi-app definitions)
|
||||
- `defaults_configs` - **[New in v2.0]** List of defaults-level HAProxy configs (e.g., log-format)
|
||||
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Simple config injection
|
||||
# Simple config injection (backend-level)
|
||||
return PluginResult(
|
||||
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)
|
||||
return PluginResult()
|
||||
```
|
||||
|
|
@ -439,12 +545,13 @@ Manages plugin loading, configuration, and execution.
|
|||
class PluginManager:
|
||||
"""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
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
|
|
@ -454,6 +561,9 @@ class PluginManager:
|
|||
def configure_plugins(self, plugins_config: dict) -> None:
|
||||
"""Configure all loaded plugins with their settings"""
|
||||
|
||||
def initialize_plugins(self) -> None:
|
||||
"""[New in v2.0] Initialize all plugins and process resource requests"""
|
||||
|
||||
def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
|
||||
"""Execute all global plugins"""
|
||||
|
||||
|
|
@ -461,6 +571,10 @@ class PluginManager:
|
|||
"""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.
|
||||
|
||||
---
|
||||
|
|
@ -598,7 +712,7 @@ The plugin creates:
|
|||
|
||||
Configuration:
|
||||
- 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])
|
||||
- index_file: Default index file (default: index.php)
|
||||
- path_info: Enable PATH_INFO support (default: true)
|
||||
|
|
@ -608,7 +722,7 @@ Example YAML config:
|
|||
plugins:
|
||||
fastcgi:
|
||||
enabled: true
|
||||
document_root: /var/www/html
|
||||
document_root: /etc/easyhaproxy/www
|
||||
index_file: index.php
|
||||
path_info: true
|
||||
|
||||
|
|
@ -626,7 +740,7 @@ import sys
|
|||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import logger_easyhaproxy
|
||||
|
||||
|
||||
class FastcgiPlugin(PluginInterface):
|
||||
|
|
@ -634,7 +748,7 @@ class FastcgiPlugin(PluginInterface):
|
|||
|
||||
def __init__(self):
|
||||
self.enabled = True
|
||||
self.document_root = "/var/www/html"
|
||||
self.document_root = "/etc/easyhaproxy/www"
|
||||
self.script_filename = "%[path]"
|
||||
self.index_file = "index.php"
|
||||
self.path_info = True
|
||||
|
|
@ -720,11 +834,10 @@ class FastcgiPlugin(PluginInterface):
|
|||
|
||||
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 = {
|
||||
"domain": context.domain,
|
||||
"fcgi_app_name": fcgi_app_name,
|
||||
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
|
||||
"document_root": self.document_root,
|
||||
"index_file": self.index_file,
|
||||
"path_info": self.path_info,
|
||||
|
|
@ -734,7 +847,8 @@ class FastcgiPlugin(PluginInterface):
|
|||
return PluginResult(
|
||||
haproxy_config=backend_config, # use-fcgi-app directive for the backend
|
||||
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
|
||||
issuer: https://myaccount.auth0.com/
|
||||
audience: https://api.mywebsite.com
|
||||
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem
|
||||
pubkey_path: /etc/easyhaproxy/jwt_keys/pubkey.pem
|
||||
paths:
|
||||
- /api/admin
|
||||
- /api/sensitive
|
||||
|
|
@ -782,7 +896,7 @@ Example Container Label:
|
|||
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
|
||||
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/
|
||||
easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
|
||||
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||
"""
|
||||
|
|
@ -794,8 +908,15 @@ import sys
|
|||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from plugins import (
|
||||
InitializationResult,
|
||||
PluginInterface,
|
||||
PluginType,
|
||||
PluginContext,
|
||||
PluginResult,
|
||||
ResourceRequest
|
||||
)
|
||||
from functions import Functions, logger_easyhaproxy
|
||||
|
||||
|
||||
class JwtValidatorPlugin(PluginInterface):
|
||||
|
|
@ -810,6 +931,8 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
self.pubkey = None # Public key content (alternative to pubkey_path)
|
||||
self.paths = [] # List of paths that require JWT validation
|
||||
self.only_paths = False # If true, only specified paths are accessible
|
||||
# Make JWT_KEYS_DIR configurable via environment variable
|
||||
self.jwt_keys_dir = os.getenv("EASYHAPROXY_JWT_KEYS_DIR", "/etc/easyhaproxy/jwt_keys")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
|
|
@ -874,6 +997,19 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
if "only_paths" in config:
|
||||
self.only_paths = str(config["only_paths"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
def initialize(self) -> InitializationResult:
|
||||
"""
|
||||
Initialize plugin resources - create JWT keys directory (new in v2.0)
|
||||
|
||||
Returns:
|
||||
InitializationResult with directory creation request
|
||||
"""
|
||||
return InitializationResult(
|
||||
resources=[
|
||||
ResourceRequest(resource_type="directory", path=self.jwt_keys_dir)
|
||||
]
|
||||
)
|
||||
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
"""
|
||||
Generate HAProxy config to validate JWT tokens
|
||||
|
|
@ -893,9 +1029,17 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
elif self.pubkey:
|
||||
# Generate path for pubkey based on domain
|
||||
domain_safe = context.domain.replace(".", "_").replace(":", "_")
|
||||
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem"
|
||||
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:
|
||||
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()
|
||||
|
||||
# Build HAProxy configuration
|
||||
|
|
@ -1021,7 +1165,7 @@ import time
|
|||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import logger_easyhaproxy
|
||||
|
||||
|
||||
class CleanupPlugin(PluginInterface):
|
||||
|
|
@ -1057,7 +1201,7 @@ class CleanupPlugin(PluginInterface):
|
|||
try:
|
||||
self.max_idle_time = int(config["max_idle_time"])
|
||||
except ValueError:
|
||||
loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
|
||||
logger_easyhaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
|
||||
|
||||
if "cleanup_temp_files" in config:
|
||||
self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"]
|
||||
|
|
@ -1095,15 +1239,15 @@ class CleanupPlugin(PluginInterface):
|
|||
if file_age > self.max_idle_time:
|
||||
os.remove(filepath)
|
||||
cleanup_actions.append(f"Removed old temp file: {filepath}")
|
||||
loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}")
|
||||
logger_easyhaproxy.debug(f"Cleanup plugin: Removed {filepath}")
|
||||
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:
|
||||
loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
|
||||
logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
|
||||
|
||||
# Log cleanup summary
|
||||
if cleanup_actions:
|
||||
loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
|
||||
logger_easyhaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
|
||||
|
||||
return PluginResult(
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
|
|
@ -1130,7 +1369,7 @@ def configure(self, config: dict) -> None:
|
|||
try:
|
||||
self.port = int(config["port"])
|
||||
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
|
||||
```
|
||||
|
||||
|
|
@ -1140,7 +1379,7 @@ def configure(self, config: dict) -> None:
|
|||
self.port = int(config["port"]) # Crashes if not an integer!
|
||||
```
|
||||
|
||||
### 2. Configuration Validation
|
||||
### 4. Configuration Validation
|
||||
|
||||
Validate configuration during `configure()` phase, not during `process()`.
|
||||
|
||||
|
|
@ -1153,7 +1392,7 @@ def configure(self, config: dict) -> None:
|
|||
|
||||
# Validate 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
|
||||
```
|
||||
|
||||
|
|
@ -1165,7 +1404,7 @@ def process(self, context: PluginContext) -> PluginResult:
|
|||
raise ValueError("No IPs configured")
|
||||
```
|
||||
|
||||
### 3. Use Metadata for Debugging
|
||||
### 5. Use Metadata for Debugging
|
||||
|
||||
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).
|
||||
|
||||
|
|
@ -1192,7 +1431,7 @@ def configure(self, config: dict) -> None:
|
|||
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.
|
||||
|
||||
|
|
@ -1209,7 +1448,7 @@ def configure(self, config: dict) -> None:
|
|||
self.paths = []
|
||||
```
|
||||
|
||||
### 6. Use Descriptive Names
|
||||
### 8. Use Descriptive Names
|
||||
|
||||
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?
|
||||
```
|
||||
|
||||
### 7. Document Your Plugin
|
||||
### 9. Document Your Plugin
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -1271,27 +1510,27 @@ def process(self, context: PluginContext) -> PluginResult:
|
|||
# Plugin logic here...
|
||||
```
|
||||
|
||||
### 9. Use Logger Appropriately
|
||||
### 11. Use Logger Appropriately
|
||||
|
||||
Use appropriate log levels for different messages.
|
||||
|
||||
```python
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import logger_easyhaproxy
|
||||
|
||||
# For debugging
|
||||
loggerEasyHaproxy.debug(f"Processing domain: {context.domain}")
|
||||
logger_easyhaproxy.debug(f"Processing domain: {context.domain}")
|
||||
|
||||
# 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)
|
||||
loggerEasyHaproxy.warning(f"Invalid configuration value, using default")
|
||||
logger_easyhaproxy.warning(f"Invalid configuration value, using default")
|
||||
|
||||
# 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.
|
||||
|
||||
|
|
@ -1446,7 +1685,7 @@ services:
|
|||
- "443:443"
|
||||
volumes:
|
||||
- /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:
|
||||
- EASYHAPROXY_DISCOVER=docker
|
||||
```
|
||||
|
|
@ -1468,7 +1707,7 @@ Expected output:
|
|||
**Verify generated configuration:**
|
||||
|
||||
```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**
|
||||
```bash
|
||||
ls -la /etc/haproxy/plugins/
|
||||
ls -la /etc/easyhaproxy/plugins/
|
||||
# Ensure my_plugin.py exists
|
||||
```
|
||||
|
||||
2. **Invalid Python syntax**
|
||||
```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
|
||||
```
|
||||
|
||||
|
|
@ -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**
|
||||
```bash
|
||||
# Test configuration manually:
|
||||
haproxy -c -f /etc/haproxy/haproxy.cfg
|
||||
haproxy -c -f /etc/easyhaproxy/haproxy/haproxy.cfg
|
||||
```
|
||||
|
||||
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**
|
||||
```python
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
loggerEasyHaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}")
|
||||
loggerEasyHaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}")
|
||||
logger_easyhaproxy.debug(f"Plugin {self.name} processing domain: {context.domain}")
|
||||
logger_easyhaproxy.debug(f"Plugin config: enabled={self.enabled}, setting={self.my_setting}")
|
||||
# ... rest of plugin logic
|
||||
```
|
||||
|
||||
|
|
@ -1630,7 +1869,7 @@ docker-compose exec haproxy cat /etc/haproxy/haproxy.cfg | grep -A 5 "My Plugin"
|
|||
# Risky operation
|
||||
result = self.do_something_risky()
|
||||
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
|
||||
```
|
||||
|
||||
|
|
@ -1668,7 +1907,7 @@ Share your plugin as a single `.py` file:
|
|||
|
||||
```bash
|
||||
# Users copy the file to their plugins directory:
|
||||
cp my_plugin.py /etc/haproxy/plugins/
|
||||
cp my_plugin.py /etc/easyhaproxy/plugins/
|
||||
```
|
||||
|
||||
**Advantages:**
|
||||
|
|
@ -1696,7 +1935,7 @@ my-easyhaproxy-plugin/
|
|||
**Installation:**
|
||||
```bash
|
||||
# Users download and install:
|
||||
wget https://raw.githubusercontent.com/user/my-plugin/main/my_plugin.py -O /etc/haproxy/plugins/my_plugin.py
|
||||
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
|
||||
|
|
@ -1710,7 +1949,7 @@ FROM byjg/easy-haproxy:latest
|
|||
COPY my_plugin.py /app/src/plugins/builtin/
|
||||
|
||||
# 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:**
|
||||
|
|
@ -1738,7 +1977,7 @@ Brief description of what your plugin does.
|
|||
|
||||
### Docker
|
||||
\`\`\`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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 14
|
||||
sidebar_position: 15
|
||||
---
|
||||
|
||||
# Using Plugins
|
||||
|
|
@ -71,7 +71,7 @@ metadata:
|
|||
easyhaproxy.plugin.jwt_validator.algorithm: "RS256"
|
||||
easyhaproxy.plugin.jwt_validator.issuer: "https://auth.example.com/"
|
||||
easyhaproxy.plugin.jwt_validator.audience: "https://api.example.com"
|
||||
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/haproxy/jwt_keys/api_pubkey.pem"
|
||||
easyhaproxy.plugin.jwt_validator.pubkey_path: "/etc/easyhaproxy/jwt_keys/api_pubkey.pem"
|
||||
easyhaproxy.plugin.jwt_validator.paths: "/api/admin,/api/users"
|
||||
easyhaproxy.plugin.jwt_validator.only_paths: "false"
|
||||
# Configure deny_pages plugin
|
||||
|
|
@ -124,7 +124,7 @@ services:
|
|||
|
||||
### 3. Static YAML Configuration
|
||||
|
||||
Configure plugins in `/etc/haproxy/static/config.yaml`:
|
||||
Configure plugins in `/etc/easyhaproxy/static/config.yaml`:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
|
|
@ -200,9 +200,9 @@ services:
|
|||
easyhaproxy.http.plugins: jwt_validator
|
||||
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth0.myapp.com/
|
||||
easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
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:**
|
||||
|
|
@ -213,11 +213,11 @@ services:
|
|||
labels:
|
||||
easyhaproxy.http.host: api.example.com
|
||||
easyhaproxy.http.plugins: jwt_validator
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/users,/api/billing
|
||||
easyhaproxy.http.plugin.jwt_validator.only_paths: false
|
||||
volumes:
|
||||
- ./auth_pubkey.pem:/etc/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
|
||||
```
|
||||
|
||||
|
|
@ -229,11 +229,11 @@ services:
|
|||
labels:
|
||||
easyhaproxy.http.host: api.example.com
|
||||
easyhaproxy.http.plugins: jwt_validator
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.paths: /api/v1,/api/v2
|
||||
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||
volumes:
|
||||
- ./auth_pubkey.pem:/etc/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
|
||||
```
|
||||
|
||||
|
|
@ -287,7 +287,7 @@ labels:
|
|||
Keep your system clean with automatic temp file removal:
|
||||
|
||||
```yaml
|
||||
# /etc/haproxy/static/config.yaml
|
||||
# /etc/easyhaproxy/static/config.yaml
|
||||
plugins:
|
||||
enabled: [cleanup]
|
||||
config:
|
||||
|
|
@ -346,13 +346,13 @@ EASYHAPROXY_LOG_LEVEL=DEBUG
|
|||
INFO: Loaded builtin plugin: cloudflare (domain)
|
||||
INFO: Loaded builtin plugin: cleanup (global)
|
||||
DEBUG: Executing domain plugin: cloudflare for domain: example.com
|
||||
DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/haproxy/cloudflare_ips.lst'}
|
||||
DEBUG: Plugin cloudflare metadata: {'domain': 'example.com', 'ip_list_path': '/etc/easyhaproxy/cloudflare_ips.lst'}
|
||||
```
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
**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
|
||||
3. Plugin class inherits from `PluginInterface`
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
## 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`
|
||||
- [Setup certificate as a label definition](#setup-certificate-as-a-label-definition-in-docker-container)
|
||||
|
||||
## 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:
|
||||
|
||||
|
|
@ -52,7 +41,7 @@ docker volume create certs_haproxy
|
|||
|
||||
docker run \
|
||||
/* other parameters */
|
||||
-v certs_haproxy:/certs/haproxy \
|
||||
-v certs_haproxy:/etc/easyhaproxy/certs/haproxy \
|
||||
-d byjg/easy-haproxy
|
||||
```
|
||||
|
||||
|
|
@ -77,8 +66,79 @@ MIIEojCCA4qgAwIBAgIUegW2BimwuL4RzRZ2WYkHA6U5nkAwDQYJKoZIhvcNAQEL
|
|||
3. Copy this certificate to EasyHAProxy volume:
|
||||
|
||||
```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)
|
||||
|
|
|
|||
|
|
@ -29,45 +29,49 @@ ssl_mode: default
|
|||
logLevel:
|
||||
haproxy: INFO
|
||||
|
||||
certbot: {
|
||||
"email": "acme@example.org"
|
||||
}
|
||||
certbot:
|
||||
email: "acme@example.org"
|
||||
|
||||
easymapping:
|
||||
- port: 80
|
||||
hosts:
|
||||
host1.com.br:
|
||||
containers:
|
||||
- container:5000
|
||||
certbot: true
|
||||
redirect_ssl: true
|
||||
host2.com.br:
|
||||
containers:
|
||||
- other:3000
|
||||
redirect:
|
||||
www.host1.com.br: http://host1.com.br
|
||||
containers:
|
||||
# HTTP with certbot + redirect to HTTPS
|
||||
"host1.com.br:80":
|
||||
ip: ["container:5000"]
|
||||
certbot: true
|
||||
redirect_ssl: true
|
||||
|
||||
- port: 443
|
||||
hosts:
|
||||
host1.com.br:
|
||||
containers:
|
||||
- container:80
|
||||
redirect_ssl: false
|
||||
ssl: true
|
||||
# Additional HTTP host
|
||||
"host2.com.br:80":
|
||||
ip: ["other:3000"]
|
||||
|
||||
- port: 8080
|
||||
hosts:
|
||||
host3.com.br:
|
||||
containers:
|
||||
- domain:8181
|
||||
# Redirect www → main domain
|
||||
"www.host1.com.br:80":
|
||||
ip: ["container:5000"]
|
||||
redirect_ssl: true
|
||||
|
||||
# HTTPS version
|
||||
"host1.com.br:443":
|
||||
ip: ["container:80"]
|
||||
ssl: true
|
||||
|
||||
# Different host on different port
|
||||
"host3.com.br:8080":
|
||||
ip: ["domain:8181"]
|
||||
```
|
||||
|
||||
Then map this file to `/etc/haproxy/static/config.yml` in your EasyHAProxy container:
|
||||
:::info New Configuration Format
|
||||
The `containers` format simplifies static configuration:
|
||||
- **Flatter structure**: `"hostname:port"` keys instead of nested `easymapping` → `ports` → `hosts`
|
||||
- **Better readability**: Port and localport embedded in keys (`"host:port"` and `"container:localport"`)
|
||||
- **Plugin support**: Global and per-host plugin configuration
|
||||
- **Clearer mapping**: Format mirrors internal Docker label structure
|
||||
:::
|
||||
|
||||
Then map this file to `/etc/easyhaproxy/static/config.yml` in your EasyHAProxy container:
|
||||
|
||||
```bash title="Run EasyHAProxy with static configuration"
|
||||
docker run -d \
|
||||
--name easy-haproxy-container \
|
||||
-v /my/static/:/etc/haproxy/static/ \
|
||||
-v /my/static/:/etc/easyhaproxy/static/ \
|
||||
-e EASYHAPROXY_DISCOVER="static" \
|
||||
# + Environment Variables \
|
||||
-p 80:80 \
|
||||
|
|
@ -109,19 +113,23 @@ certbot:
|
|||
retry_count: 60 # If the certificate reaches the Rate Limit, try again after 'n' iterations.
|
||||
}
|
||||
|
||||
easymapping:
|
||||
- port: 80 # Listen port
|
||||
containers:
|
||||
# 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
|
||||
hosts:
|
||||
host1.com.br: # Hostname
|
||||
containers:
|
||||
- container:5000 # Endpoints of the hostname above (ip, dns, container, etc)
|
||||
certbot: true # Optional. it will request a certbot certificate. Needs certbot.email set.
|
||||
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
|
||||
clone_to_ssl: true # Optional. Default False. You clone these hosts to its equivalent SSL.
|
||||
redirect:
|
||||
www.host1.com.br: http://host1.com.br
|
||||
|
||||
# HTTPS version (SSL)
|
||||
"host1.com.br:443":
|
||||
ip: ["container:80"]
|
||||
ssl: true # Enable SSL for this port
|
||||
|
||||
# Redirect www → main domain (using redirect_ssl with backend)
|
||||
"www.host1.com.br:80":
|
||||
ip: ["container:5000"]
|
||||
redirect_ssl: true
|
||||
```
|
||||
|
||||
:::note SSL Certificates in Static Mode
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ And then deploy the EasyHAProxy stack:
|
|||
```yaml
|
||||
services:
|
||||
haproxy:
|
||||
image: byjg/easy-haproxy:5.0.0
|
||||
image: byjg/easy-haproxy:6.0.0
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
deploy:
|
||||
|
|
|
|||
194
docs/volumes.md
194
docs/volumes.md
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
sidebar_position: 13
|
||||
sidebar_position: 14
|
||||
---
|
||||
|
||||
# Volumes
|
||||
|
|
@ -8,15 +8,191 @@ sidebar_position: 13
|
|||
These volumes allow you to persist certificates, provide custom configurations, and extend EasyHAProxy functionality.
|
||||
:::
|
||||
|
||||
You can map the following volumes:
|
||||
## Directory Structure
|
||||
|
||||
| Volume | Description |
|
||||
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------|
|
||||
| /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). |
|
||||
| /etc/haproxy/conf.d/ | The folder that will contain the [custom configuration](other.md) files. |
|
||||
| /etc/haproxy/errors-custom/ | The folder that will contain the [custom error](other.md) html files. |
|
||||
:::info Base Path
|
||||
All EasyHAProxy files are organized under `/etc/easyhaproxy/`. This can be customized using the `EASYHAPROXY_BASE_PATH` environment variable.
|
||||
:::
|
||||
|
||||
```plaintext title="/etc/easyhaproxy/ Directory Tree"
|
||||
/etc/easyhaproxy/
|
||||
├── 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# to the chart and its templates, including the app version.
|
||||
# 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
|
||||
# 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.
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ rules:
|
|||
resources:
|
||||
# - configmaps
|
||||
# - endpoints
|
||||
# - nodes
|
||||
- nodes
|
||||
- pods
|
||||
- services
|
||||
- namespaces
|
||||
|
|
@ -28,23 +28,21 @@ rules:
|
|||
- list
|
||||
- watch
|
||||
- apiGroups:
|
||||
- "extensions"
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses
|
||||
# - ingresses/status
|
||||
# - ingressclasses
|
||||
- ingresses/status
|
||||
- ingressclasses
|
||||
verbs:
|
||||
- get
|
||||
- list
|
||||
- watch
|
||||
# - apiGroups:
|
||||
# - "extensions"
|
||||
# - "networking.k8s.io"
|
||||
# resources:
|
||||
# - ingresses/status
|
||||
# verbs:
|
||||
# - update
|
||||
- apiGroups:
|
||||
- "networking.k8s.io"
|
||||
resources:
|
||||
- ingresses/status
|
||||
verbs:
|
||||
- patch
|
||||
- apiGroups:
|
||||
- ""
|
||||
resources:
|
||||
|
|
|
|||
|
|
@ -77,4 +77,18 @@ spec:
|
|||
{{- if .Values.easyhaproxy.certbot.email }}
|
||||
- name: 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: {}
|
||||
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: {}
|
||||
|
||||
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:
|
||||
scripts/bump-version.sh <new-version>
|
||||
scripts/bump-version.sh --verify <new-version>
|
||||
scripts/bump-version.sh --current
|
||||
|
||||
Description:
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
if [[ "${1:-}" == "--verify" ]]; then
|
||||
MODE="verify"
|
||||
|
|
@ -31,10 +52,23 @@ if [[ "$NEW_VERSION" == "latest" ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
# Extract base version (strip pre-release suffix for RELEASE.md check)
|
||||
# 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_CHART_VERSION_RAW=$(grep '^version:' "$CHART_FILE" | head -1 | awk '{print $2}')
|
||||
# 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 "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 "version = \"$NEW_VERSION\"" pyproject.toml "pyproject.toml version"
|
||||
|
||||
if grep -R "byjg/easy-haproxy:" examples | grep -v "$NEW_VERSION" >/dev/null; then
|
||||
echo "❌ Examples still reference a different tag. Run bump-version.sh to update."
|
||||
if grep -R --include='*.yml' "byjg/easy-haproxy:[0-9]" tests_e2e | grep -v "$NEW_VERSION" >/dev/null; then
|
||||
echo "❌ Examples (tests_e2e) still reference a different tag. Run bump-version.sh to update."
|
||||
STATUS=1
|
||||
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
|
||||
|
||||
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#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" docs/kubernetes.md
|
||||
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#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/^\\*\\*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
|
||||
sed -i "s#appVersion: \"[a-zA-Z0-9\\.-]*\"#appVersion: \"$NEW_VERSION\"#g" "$CHART_FILE"
|
||||
|
||||
# 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
|
||||
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)
|
||||
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/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 "Next steps:"
|
||||
echo " 1) git status (review changes)"
|
||||
echo " 2) git add ."
|
||||
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 " 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
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from .config_generator import HaproxyConfigGenerator
|
||||
from .label_handler import DockerLabelHandler
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
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()
|
||||
__all__ = ["DockerLabelHandler", "HaproxyConfigGenerator"]
|
||||
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
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import psutil
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from multiprocessing import Process
|
||||
from typing import Final
|
||||
import requests
|
||||
from OpenSSL import crypto
|
||||
|
||||
class ContainerEnv:
|
||||
@staticmethod
|
||||
def read():
|
||||
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",
|
||||
}
|
||||
|
||||
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)
|
||||
from .certbot import Certbot
|
||||
from .consts import Consts, classproperty
|
||||
from .container_env import ContainerEnv
|
||||
from .filter import SingleLineNonEmptyFilter
|
||||
from .functions import Functions
|
||||
from .haproxy import DaemonizeHAProxy
|
||||
from .loggers import logger_certbot, logger_easyhaproxy, logger_haproxy, logger_init
|
||||
|
||||
__all__ = [
|
||||
"Certbot",
|
||||
"classproperty",
|
||||
"Consts",
|
||||
"ContainerEnv",
|
||||
"DaemonizeHAProxy",
|
||||
"Functions",
|
||||
"SingleLineNonEmptyFilter",
|
||||
"logger_certbot",
|
||||
"logger_easyhaproxy",
|
||||
"logger_haproxy",
|
||||
"logger_init",
|
||||
]
|
||||
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
|
||||
import importlib.util
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any, List
|
||||
from functions import loggerEasyHaproxy
|
||||
from .interface import PluginInterface
|
||||
from .manager import PluginManager
|
||||
from .types import InitializationResult, PluginContext, PluginResult, PluginType, ResourceRequest
|
||||
|
||||
|
||||
class PluginType(Enum):
|
||||
"""Plugin execution types"""
|
||||
GLOBAL = "global" # Execute once per discovery cycle
|
||||
DOMAIN = "domain" # Execute per domain/host
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginContext:
|
||||
"""Container for all plugin execution data"""
|
||||
parsed_object: dict # {IP: labels} from discovery
|
||||
easymapping: list # Current HAProxy mapping structure
|
||||
container_env: dict # Environment configuration
|
||||
domain: Optional[str] = None # Domain name (for DOMAIN plugins)
|
||||
port: Optional[str] = None # Port (for DOMAIN plugins)
|
||||
host_config: Optional[dict] = None # Domain-specific config
|
||||
|
||||
|
||||
@dataclass
|
||||
class PluginResult:
|
||||
"""Plugin execution result"""
|
||||
haproxy_config: str = "" # HAProxy config snippet to inject
|
||||
modified_easymapping: Optional[list] = None # Modified easymapping structure
|
||||
metadata: Dict[str, Any] = field(default_factory=dict) # Plugin metadata for logging
|
||||
|
||||
|
||||
class PluginInterface(ABC):
|
||||
"""Base class all plugins must inherit"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Return the unique plugin name"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def plugin_type(self) -> PluginType:
|
||||
"""Return the plugin type (GLOBAL or DOMAIN)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: dict) -> None:
|
||||
"""
|
||||
Configure the plugin with settings from YAML/env/labels
|
||||
|
||||
Args:
|
||||
config: Dictionary with plugin-specific configuration
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def process(self, context: PluginContext) -> PluginResult:
|
||||
"""
|
||||
Process the plugin logic and return result
|
||||
|
||||
Args:
|
||||
context: PluginContext with all necessary data
|
||||
|
||||
Returns:
|
||||
PluginResult with HAProxy config snippets and/or modified data
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages plugin loading, configuration, and execution"""
|
||||
|
||||
def __init__(self, plugins_dir: str = "/etc/haproxy/plugins", abort_on_error: bool = False):
|
||||
"""
|
||||
Initialize the plugin manager
|
||||
|
||||
Args:
|
||||
plugins_dir: Directory containing plugin files
|
||||
abort_on_error: If True, abort on plugin errors; if False, log and continue
|
||||
"""
|
||||
self.plugins_dir = plugins_dir
|
||||
self.abort_on_error = abort_on_error
|
||||
self.plugins: Dict[str, PluginInterface] = {}
|
||||
self.global_plugins: List[PluginInterface] = []
|
||||
self.domain_plugins: List[PluginInterface] = []
|
||||
self.logger = loggerEasyHaproxy
|
||||
|
||||
def load_plugins(self) -> None:
|
||||
"""
|
||||
Discover and load plugins from the plugins directory
|
||||
Loads both builtin plugins and external plugins
|
||||
"""
|
||||
# Load builtin plugins first
|
||||
builtin_dir = os.path.join(os.path.dirname(__file__), "builtin")
|
||||
self._load_plugins_from_directory(builtin_dir, "builtin")
|
||||
|
||||
# Load external plugins from /etc/haproxy/plugins
|
||||
if os.path.exists(self.plugins_dir):
|
||||
self._load_plugins_from_directory(self.plugins_dir, "external")
|
||||
else:
|
||||
self.logger.info(f"Plugin directory {self.plugins_dir} does not exist, skipping external plugins")
|
||||
|
||||
def _load_plugins_from_directory(self, directory: str, source: str) -> None:
|
||||
"""
|
||||
Load plugins from a specific directory
|
||||
|
||||
Args:
|
||||
directory: Path to directory containing plugins
|
||||
source: Source identifier ("builtin" or "external")
|
||||
"""
|
||||
if not os.path.exists(directory):
|
||||
return
|
||||
|
||||
for filename in os.listdir(directory):
|
||||
if filename.endswith(".py") and not filename.startswith("__"):
|
||||
filepath = os.path.join(directory, filename)
|
||||
module_name = f"plugins.{source}.{filename[:-3]}"
|
||||
|
||||
try:
|
||||
# Load module from file
|
||||
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Find plugin classes in module
|
||||
for item_name in dir(module):
|
||||
item = getattr(module, item_name)
|
||||
if (isinstance(item, type) and
|
||||
issubclass(item, PluginInterface) and
|
||||
item is not PluginInterface):
|
||||
# Instantiate plugin
|
||||
plugin = item()
|
||||
self.plugins[plugin.name] = plugin
|
||||
|
||||
# Categorize by type
|
||||
if plugin.plugin_type == PluginType.GLOBAL:
|
||||
self.global_plugins.append(plugin)
|
||||
elif plugin.plugin_type == PluginType.DOMAIN:
|
||||
self.domain_plugins.append(plugin)
|
||||
|
||||
self.logger.debug(f"Loaded {source} plugin: {plugin.name} ({plugin.plugin_type.value})")
|
||||
|
||||
except Exception as e:
|
||||
self._handle_error(f"Failed to load plugin from {filepath}: {str(e)}")
|
||||
|
||||
def configure_plugins(self, plugins_config: dict) -> None:
|
||||
"""
|
||||
Configure all loaded plugins with their settings
|
||||
|
||||
Args:
|
||||
plugins_config: Plugin configuration from YAML/env
|
||||
Format: {"plugin_name": {"key": "value"}, ...}
|
||||
"""
|
||||
for plugin_name, plugin in self.plugins.items():
|
||||
try:
|
||||
# Get plugin-specific config
|
||||
plugin_cfg = plugins_config.get(plugin_name, {})
|
||||
|
||||
# Also check "config" sub-key for env var configs
|
||||
if "config" in plugins_config and plugin_name in plugins_config["config"]:
|
||||
plugin_cfg.update(plugins_config["config"][plugin_name])
|
||||
|
||||
# Configure plugin
|
||||
plugin.configure(plugin_cfg)
|
||||
self.logger.debug(f"Configured plugin: {plugin_name} with config: {plugin_cfg}")
|
||||
|
||||
except Exception as e:
|
||||
self._handle_error(f"Failed to configure plugin '{plugin_name}': {str(e)}")
|
||||
|
||||
def execute_global_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
|
||||
"""
|
||||
Execute all global plugins
|
||||
|
||||
Args:
|
||||
context: PluginContext with execution data
|
||||
enabled_list: Optional list of plugin names to execute. If None, execute all.
|
||||
|
||||
Returns:
|
||||
List of PluginResult from each plugin
|
||||
"""
|
||||
results = []
|
||||
|
||||
for plugin in self.global_plugins:
|
||||
# Check if plugin is in enabled list (if provided)
|
||||
if enabled_list is not None and plugin.name not in enabled_list:
|
||||
continue
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Executing global plugin: {plugin.name}")
|
||||
result = plugin.process(context)
|
||||
results.append(result)
|
||||
|
||||
if result.metadata:
|
||||
self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}")
|
||||
|
||||
except Exception as e:
|
||||
self._handle_error(f"Global plugin '{plugin.name}' failed: {str(e)}")
|
||||
|
||||
return results
|
||||
|
||||
def execute_domain_plugins(self, context: PluginContext, enabled_list: Optional[List[str]] = None) -> List[PluginResult]:
|
||||
"""
|
||||
Execute all domain plugins for a specific domain
|
||||
|
||||
Args:
|
||||
context: PluginContext with domain-specific data
|
||||
enabled_list: Optional list of plugin names to execute. If None, execute all.
|
||||
|
||||
Returns:
|
||||
List of PluginResult from each plugin
|
||||
"""
|
||||
results = []
|
||||
|
||||
for plugin in self.domain_plugins:
|
||||
# Check if plugin is in enabled list (if provided)
|
||||
if enabled_list is not None and plugin.name not in enabled_list:
|
||||
continue
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Executing domain plugin: {plugin.name} for domain: {context.domain}")
|
||||
result = plugin.process(context)
|
||||
results.append(result)
|
||||
|
||||
if result.metadata:
|
||||
self.logger.debug(f"Plugin {plugin.name} metadata: {result.metadata}")
|
||||
|
||||
except Exception as e:
|
||||
self._handle_error(f"Domain plugin '{plugin.name}' failed for domain '{context.domain}': {str(e)}")
|
||||
|
||||
return results
|
||||
|
||||
def _handle_error(self, message: str) -> None:
|
||||
"""
|
||||
Handle plugin errors according to abort_on_error setting
|
||||
|
||||
Args:
|
||||
message: Error message to log
|
||||
"""
|
||||
if self.abort_on_error:
|
||||
self.logger.error(message)
|
||||
raise RuntimeError(message)
|
||||
else:
|
||||
self.logger.warning(message)
|
||||
__all__ = [
|
||||
"InitializationResult",
|
||||
"PluginContext",
|
||||
"PluginInterface",
|
||||
"PluginManager",
|
||||
"PluginResult",
|
||||
"PluginType",
|
||||
"ResourceRequest",
|
||||
]
|
||||
|
|
@ -21,16 +21,16 @@ Example Environment Variable:
|
|||
EASYHAPROXY_PLUGIN_CLEANUP_MAX_IDLE_TIME=600
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
import time
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import logger_easyhaproxy
|
||||
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
|
||||
|
||||
|
||||
class CleanupPlugin(PluginInterface):
|
||||
|
|
@ -66,7 +66,7 @@ class CleanupPlugin(PluginInterface):
|
|||
try:
|
||||
self.max_idle_time = int(config["max_idle_time"])
|
||||
except ValueError:
|
||||
loggerEasyHaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
|
||||
logger_easyhaproxy.warning(f"Invalid max_idle_time value: {config['max_idle_time']}, using default")
|
||||
|
||||
if "cleanup_temp_files" in config:
|
||||
self.cleanup_temp_files = str(config["cleanup_temp_files"]).lower() in ["true", "1", "yes"]
|
||||
|
|
@ -104,15 +104,15 @@ class CleanupPlugin(PluginInterface):
|
|||
if file_age > self.max_idle_time:
|
||||
os.remove(filepath)
|
||||
cleanup_actions.append(f"Removed old temp file: {filepath}")
|
||||
loggerEasyHaproxy.debug(f"Cleanup plugin: Removed {filepath}")
|
||||
logger_easyhaproxy.debug(f"Cleanup plugin: Removed {filepath}")
|
||||
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:
|
||||
loggerEasyHaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
|
||||
logger_easyhaproxy.warning(f"Failed to cleanup {temp_dir}: {e}")
|
||||
|
||||
# Log cleanup summary
|
||||
if cleanup_actions:
|
||||
loggerEasyHaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
|
||||
logger_easyhaproxy.info(f"Cleanup plugin: Performed {len(cleanup_actions)} cleanup action(s)")
|
||||
|
||||
return PluginResult(
|
||||
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.
|
||||
|
||||
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)
|
||||
- update_log_format: Update HAProxy log format to show real visitor IP (default: true)
|
||||
|
||||
Example YAML config:
|
||||
plugins:
|
||||
cloudflare:
|
||||
enabled: true
|
||||
ip_list_path: /etc/haproxy/cloudflare_ips.lst
|
||||
ip_list_path: /etc/easyhaproxy/cloudflare_ips.lst
|
||||
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:
|
||||
easyhaproxy.http.plugins: "cloudflare"
|
||||
easyhaproxy.http.plugin.cloudflare.update_log_format: "true"
|
||||
|
||||
HAProxy Config Generated:
|
||||
# Cloudflare - Restore original visitor IP
|
||||
acl from_cloudflare src -f /etc/haproxy/cloudflare_ips.lst
|
||||
http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare
|
||||
acl from_cloudflare src -f /etc/easyhaproxy/cloudflare_ips.lst
|
||||
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 sys
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import logger_easyhaproxy, Consts
|
||||
from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
|
||||
|
||||
|
||||
class CloudflarePlugin(PluginInterface):
|
||||
|
|
@ -70,9 +85,11 @@ class CloudflarePlugin(PluginInterface):
|
|||
]
|
||||
|
||||
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.use_builtin_ips = True
|
||||
self.update_log_format = True
|
||||
self.ip_list = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
|
|
@ -89,18 +106,48 @@ class CloudflarePlugin(PluginInterface):
|
|||
Args:
|
||||
config: Dictionary with configuration options
|
||||
- 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
|
||||
- use_builtin_ips: Use built-in Cloudflare IP ranges (default: true)
|
||||
- update_log_format: Update HAProxy log format to show real IP (default: true)
|
||||
"""
|
||||
if "ip_list_path" in config:
|
||||
self.ip_list_path = config["ip_list_path"]
|
||||
|
||||
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:
|
||||
self.enabled = str(config["enabled"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
if "use_builtin_ips" in config:
|
||||
self.use_builtin_ips = str(config["use_builtin_ips"]).lower() in ["true", "1", "yes"]
|
||||
|
||||
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:
|
||||
"""
|
||||
Generate HAProxy config to restore original IP from Cloudflare
|
||||
|
|
@ -114,27 +161,50 @@ class CloudflarePlugin(PluginInterface):
|
|||
if not self.enabled:
|
||||
return PluginResult()
|
||||
|
||||
# Write built-in Cloudflare IPs to file if using built-in IPs
|
||||
if self.use_builtin_ips:
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
ip_list_dir = os.path.dirname(self.ip_list_path)
|
||||
if ip_list_dir and not os.path.exists(ip_list_dir):
|
||||
os.makedirs(ip_list_dir, exist_ok=True)
|
||||
# Determine which IPs to write to file
|
||||
ips_to_write = None
|
||||
ip_source = None
|
||||
|
||||
# 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:
|
||||
for ip_range in self.CLOUDFLARE_IPS:
|
||||
for ip_range in ips_to_write:
|
||||
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:
|
||||
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
|
||||
acl from_cloudflare src -f {self.ip_list_path}
|
||||
http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare"""
|
||||
http-request set-var(txn.real_ip) req.hdr(CF-Connecting-IP) if from_cloudflare
|
||||
http-request set-header X-Forwarded-For %[var(txn.real_ip)] if from_cloudflare"""
|
||||
|
||||
# Generate log format config (frontend level)
|
||||
# Industry-standard format: real_ip/proxy_ip [time] request status bytes timings
|
||||
# Based on HAProxy HTTP log format with real IP shown first
|
||||
log_format_config = None
|
||||
if self.update_log_format:
|
||||
log_format_config = """# Cloudflare - Enhanced log format showing real visitor IP
|
||||
log-format "%{+Q}[var(txn.real_ip)]:-/%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r\""""
|
||||
|
||||
return PluginResult(
|
||||
haproxy_config=haproxy_config,
|
||||
|
|
@ -142,7 +212,11 @@ http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_clo
|
|||
metadata={
|
||||
"domain": context.domain,
|
||||
"ip_list_path": self.ip_list_path,
|
||||
"ip_list_provided": self.ip_list is not None,
|
||||
"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
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ The plugin creates:
|
|||
|
||||
Configuration:
|
||||
- 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])
|
||||
- index_file: Default index file (default: index.php)
|
||||
- path_info: Enable PATH_INFO support (default: true)
|
||||
|
|
@ -39,11 +39,12 @@ Example Kubernetes Annotation:
|
|||
import os
|
||||
import sys
|
||||
|
||||
from functions import Consts
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from plugins import PluginContext, PluginInterface, PluginResult, PluginType
|
||||
|
||||
|
||||
class FastcgiPlugin(PluginInterface):
|
||||
|
|
@ -51,7 +52,7 @@ class FastcgiPlugin(PluginInterface):
|
|||
|
||||
def __init__(self):
|
||||
self.enabled = True
|
||||
self.document_root = "/var/www/html"
|
||||
self.document_root = Consts.base_path + "/www"
|
||||
self.script_filename = "%[path]"
|
||||
self.index_file = "index.php"
|
||||
self.path_info = True
|
||||
|
|
@ -124,7 +125,7 @@ class FastcgiPlugin(PluginInterface):
|
|||
|
||||
# PATH_INFO support
|
||||
if self.path_info:
|
||||
fcgi_app_lines.append(f" path-info ^(/.+\\.php)(/.*)?$")
|
||||
fcgi_app_lines.append(" path-info ^(/.+\\.php)(/.*)?$")
|
||||
|
||||
# Set SCRIPT_FILENAME if customized
|
||||
if self.script_filename and self.script_filename != "%[path]":
|
||||
|
|
@ -137,11 +138,10 @@ class FastcgiPlugin(PluginInterface):
|
|||
|
||||
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 = {
|
||||
"domain": context.domain,
|
||||
"fcgi_app_name": fcgi_app_name,
|
||||
"fcgi_app_definition": fcgi_app_definition, # For top-level injection
|
||||
"document_root": self.document_root,
|
||||
"index_file": self.index_file,
|
||||
"path_info": self.path_info,
|
||||
|
|
@ -151,5 +151,6 @@ class FastcgiPlugin(PluginInterface):
|
|||
return PluginResult(
|
||||
haproxy_config=backend_config, # use-fcgi-app directive for the backend
|
||||
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
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -9,12 +9,28 @@ Configuration:
|
|||
- algorithm: JWT signing algorithm (default: RS256)
|
||||
- issuer: Expected JWT issuer (optional, set to "none"/"null" to skip validation)
|
||||
- audience: Expected JWT audience (optional, set to "none"/"null" to skip validation)
|
||||
- pubkey_path: Path to public key file (required if pubkey not provided)
|
||||
- pubkey: Public key content as base64-encoded string (required if pubkey_path not provided)
|
||||
- pubkey_path: Path to public key file in container (priority: 1)
|
||||
- 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)
|
||||
- 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
|
||||
|
||||
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:
|
||||
- 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
|
||||
|
|
@ -31,7 +47,7 @@ Example YAML config:
|
|||
algorithm: RS256
|
||||
issuer: https://myaccount.auth0.com/
|
||||
audience: https://api.mywebsite.com
|
||||
pubkey_path: /etc/haproxy/jwt_keys/pubkey.pem
|
||||
pubkey_path: /etc/easyhaproxy/jwt_keys/pubkey.pem
|
||||
paths:
|
||||
- /api/admin
|
||||
- /api/sensitive
|
||||
|
|
@ -42,10 +58,20 @@ Example Container Label:
|
|||
easyhaproxy.http.plugin.jwt_validator.algorithm: RS256
|
||||
easyhaproxy.http.plugin.jwt_validator.issuer: https://auth.example.com/
|
||||
easyhaproxy.http.plugin.jwt_validator.audience: https://api.example.com
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/haproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.pubkey_path: /etc/easyhaproxy/jwt_keys/api_pubkey.pem
|
||||
easyhaproxy.http.plugin.jwt_validator.paths: /api/admin,/api/sensitive
|
||||
easyhaproxy.http.plugin.jwt_validator.only_paths: true
|
||||
|
||||
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:
|
||||
# JWT Validator - Validate JWT tokens
|
||||
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 'Invalid JWT issuer' unless { var(txn.iss) -m str https://auth.example.com/ }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT audience' unless { var(txn.aud) -m str https://api.example.com }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/haproxy/jwt_keys/api_pubkey.pem") -m int 1 }
|
||||
http-request deny content-type 'text/html' string 'Invalid JWT signature' unless { http_auth_bearer,jwt_verify(txn.alg,"/etc/easyhaproxy/jwt_keys/api_pubkey.pem") -m int 1 }
|
||||
|
||||
# Validate expiration
|
||||
http-request set-var(txn.now) date()
|
||||
|
|
@ -74,8 +100,8 @@ import sys
|
|||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from plugins import PluginInterface, PluginType, PluginContext, PluginResult
|
||||
from functions import loggerEasyHaproxy
|
||||
from functions import Functions, logger_easyhaproxy, Consts
|
||||
from plugins import InitializationResult, PluginContext, PluginInterface, PluginResult, PluginType, ResourceRequest
|
||||
|
||||
|
||||
class JwtValidatorPlugin(PluginInterface):
|
||||
|
|
@ -91,6 +117,8 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
self.paths = [] # List of paths that require JWT validation
|
||||
self.only_paths = False # If true, only specified paths are accessible
|
||||
self.allow_anonymous = False # If true, allow requests without Authorization header
|
||||
# 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
|
||||
def name(self) -> str:
|
||||
|
|
@ -159,6 +187,19 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
if "allow_anonymous" in config:
|
||||
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:
|
||||
"""
|
||||
Generate HAProxy config to validate JWT tokens
|
||||
|
|
@ -178,9 +219,20 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
elif self.pubkey:
|
||||
# Generate path for pubkey based on domain
|
||||
domain_safe = context.domain.replace(".", "_").replace(":", "_")
|
||||
pubkey_file = f"/etc/haproxy/jwt_keys/{domain_safe}_pubkey.pem"
|
||||
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:
|
||||
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()
|
||||
|
||||
# Build HAProxy configuration
|
||||
|
|
@ -271,6 +323,7 @@ class JwtValidatorPlugin(PluginInterface):
|
|||
if self.audience:
|
||||
metadata["audience"] = self.audience
|
||||
if self.pubkey:
|
||||
# Keep pubkey_content in metadata for backward compatibility with tests
|
||||
metadata["pubkey_content"] = self.pubkey
|
||||
if 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
|
||||
import socket
|
||||
from typing import Final
|
||||
from .docker import Docker
|
||||
from .interface import ProcessorInterface
|
||||
from .kubernetes import Kubernetes
|
||||
from .static import Static
|
||||
from .swarm import Swarm
|
||||
|
||||
import docker
|
||||
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)
|
||||
__all__ = ["ProcessorInterface", "Static", "Docker", "Swarm", "Kubernetes"]
|
||||
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 %}
|
||||
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"] %}
|
||||
bind *:{{ o["port"] }} proto h2
|
||||
option http-use-htx
|
||||
|
|
|
|||
|
|
@ -25,19 +25,28 @@ global
|
|||
|
||||
defaults
|
||||
log global
|
||||
unique-id-format %{+X}o\ %ci:%cp_%fi:%fp_%Ts_%rt:%pid
|
||||
unique-id-header X-Edge-Request-ID
|
||||
option httplog
|
||||
|
||||
timeout connect 3s
|
||||
timeout client 10s
|
||||
timeout server 10m
|
||||
{% if data["customerrors"] %}
|
||||
errorfile 400 /etc/haproxy/errors-custom/400.http
|
||||
errorfile 403 /etc/haproxy/errors-custom/403.http
|
||||
errorfile 408 /etc/haproxy/errors-custom/408.http
|
||||
errorfile 500 /etc/haproxy/errors-custom/500.http
|
||||
errorfile 502 /etc/haproxy/errors-custom/502.http
|
||||
errorfile 503 /etc/haproxy/errors-custom/503.http
|
||||
errorfile 504 /etc/haproxy/errors-custom/504.http
|
||||
errorfile 400 /etc/easyhaproxy/haproxy/errors-custom/400.http
|
||||
errorfile 403 /etc/easyhaproxy/haproxy/errors-custom/403.http
|
||||
errorfile 408 /etc/easyhaproxy/haproxy/errors-custom/408.http
|
||||
errorfile 500 /etc/easyhaproxy/haproxy/errors-custom/500.http
|
||||
errorfile 502 /etc/easyhaproxy/haproxy/errors-custom/502.http
|
||||
errorfile 503 /etc/easyhaproxy/haproxy/errors-custom/503.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 %}
|
||||
|
||||
{% if global_plugin_configs %}
|
||||
|
|
@ -53,6 +62,23 @@ frontend stats
|
|||
bind *:{{ data_stats["port"] | default(1936) }}
|
||||
mode http
|
||||
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 hide-version
|
||||
stats realm Haproxy\ Statistics
|
||||
|
|
@ -92,6 +118,8 @@ backend srv_{{ host }}
|
|||
option forwardfor
|
||||
http-request set-header X-Forwarded-Port %[dst_port]
|
||||
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" %}
|
||||
option tcp-check
|
||||
tcp-check connect{{ " ssl" if o["ssl-check"] == "ssl" }}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
tune.ssl.default-dh-param 2048
|
||||
|
||||
# intermediate configuration
|
||||
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||
ssl-default-bind-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-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-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-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-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