Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d16151120 | ||
|
|
b892438070 | ||
|
|
2a2b9739fc | ||
|
|
7732e2a2ff | ||
|
|
89c74c10cf | ||
|
|
1b557b9931 | ||
|
|
6ced2f8797 | ||
|
|
3917b6d1ae | ||
|
|
d9cc5311de | ||
|
|
f1c1954378 | ||
|
|
e190ca9f8e | ||
|
|
a204a44d42 | ||
|
|
04c98b1c1b | ||
|
|
1ff51da6f0 | ||
|
|
158ad3bde8 | ||
|
|
8b74cd5a4e | ||
|
|
eb3658b68e | ||
|
|
83fee2ff78 | ||
|
|
fedb025fb8 | ||
|
|
6448cffb91 | ||
|
|
47b9c87e1d | ||
|
|
8d04fe43fd | ||
|
|
99dfe98aaf | ||
|
|
e2290192f3 | ||
|
|
2e19513851 | ||
|
|
de221a1326 | ||
|
|
e1d479b74e | ||
|
|
131284fd0c | ||
|
|
edb02d6206 | ||
|
|
c4a9d9d7e6 | ||
|
|
657584c88e | ||
|
|
4262b24b7e | ||
|
|
ddb4c20073 | ||
|
|
69bed61697 | ||
|
|
fed3457f29 | ||
|
|
df56321167 | ||
|
|
a89cdab886 | ||
|
|
b359af15e5 | ||
|
|
9b329fa52b | ||
|
|
fd79ac2b70 | ||
|
|
e11d8c4269 | ||
|
|
c22d2cd1f4 | ||
|
|
be3bc040df | ||
|
|
873048e837 | ||
|
|
c931e54d4d | ||
|
|
3770dae20f | ||
|
|
b5bb141899 | ||
|
|
5ebc3fb8e8 | ||
|
|
df758a3fde | ||
|
|
fbb94e6dc3 | ||
|
|
58bb5b4f18 | ||
|
|
68a6f1bc27 | ||
|
|
5bdc95109e | ||
|
|
978f173814 | ||
|
|
2ba8f87c2c | ||
|
|
a3b19ce352 | ||
|
|
94af4e47c1 | ||
|
|
124a5373d2 | ||
|
|
657cd28344 | ||
|
|
91c92dd07e | ||
|
|
6cd64295d2 | ||
|
|
eadd6b798f | ||
|
|
6902daaea1 | ||
|
|
1fcb25bb88 | ||
|
|
bff18d358b | ||
|
|
1d22d789b8 | ||
|
|
adc20d6d0b | ||
|
|
71f4b9ef05 | ||
|
|
8d732318b4 | ||
|
|
7eeba0d718 | ||
|
|
76b2e85ca8 | ||
|
|
288f4eb8a9 | ||
|
|
8636b69ee1 | ||
|
|
4c4e99883b | ||
|
|
b293588eef | ||
|
|
b55a2fa691 | ||
|
|
2889fda014 | ||
|
|
78ebfef497 | ||
|
|
cfabd39727 | ||
|
|
0ee9e6cba8 | ||
|
|
ee8223c25f | ||
|
|
65248680a5 | ||
|
|
0a75d1b44e | ||
|
|
e2f350ce95 | ||
|
|
002e79b565 | ||
|
|
402c48b4a0 | ||
|
|
8c7031fd6d | ||
|
|
31801a6c1d | ||
|
|
6a4379c4a1 | ||
|
|
e54b4b4afe | ||
|
|
0a4995266c | ||
|
|
2cd1db7461 | ||
|
|
b88da4c58f | ||
|
|
948fdecf52 | ||
|
|
2b31fb9f4f | ||
|
|
5ce4f910c2 | ||
|
|
de3a68b59c | ||
|
|
f3569402d3 | ||
|
|
99435ee3e0 | ||
|
|
1eed03a3b6 | ||
|
|
2406d9f995 | ||
|
|
15c7f40b2e | ||
|
|
58fa6d8aba | ||
|
|
7869b81f27 | ||
|
|
ca37a68255 | ||
|
|
a7ce40f600 | ||
|
|
d4f54aef35 | ||
|
|
fac6cef0db | ||
|
|
27f3f8959b | ||
|
|
ef488a253d | ||
|
|
7b0b4c0476 | ||
|
|
f58dbef3c5 | ||
|
|
ac32141b34 | ||
|
|
bbd6a0c22c |
@@ -0,0 +1,215 @@
|
|||||||
|
---
|
||||||
|
name: haproxy-manager-deploy
|
||||||
|
description: Use when shipping a haproxy-manager-base code change — editing templates, the Dockerfile, the Python manager, the coraza-spoa subdir, or static assets like errors/, then getting it onto whp01 or staging. Trigger eagerly on phrases like "deploy haproxy", "ship the haproxy change", "rebuild haproxy-manager", "update the WAF block page", "recreate haproxy-manager", or any time the next step would involve `git push` from this repo, `docker pull` on the image, or `container-manager.sh recreate`. Walks the Gitea-CI-auto-build + recreate flow, surfaces the named-volume shadowing foot-gun, and includes post-deploy verification.
|
||||||
|
---
|
||||||
|
|
||||||
|
# haproxy-manager-base commit / build / deploy
|
||||||
|
|
||||||
|
This is procedural discipline for changes to `haproxy-manager-base`. The repository builds via Gitea Actions on push, not via a local build script (the WHP flow uses `build-release.sh`; this one doesn't — don't conflate them, see the `whp-deploy` skill in the whp repo for that one). Each step has caught a real foot-gun.
|
||||||
|
|
||||||
|
## The pipeline at a glance
|
||||||
|
|
||||||
|
```
|
||||||
|
edit code (local)
|
||||||
|
└─> commit + push
|
||||||
|
└─> Gitea Actions auto-build (build-push.yaml / build-push-coraza.yaml)
|
||||||
|
├─> publishes :latest tag to repo.anhonesthost.net
|
||||||
|
└─> wait for image (~2-4 min)
|
||||||
|
└─> recreate container on target server
|
||||||
|
└─> verify
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not skip the verify step. The container can come up "healthy" while still serving stale config or missing a baked-in file (see Step 5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0a — Resolve the target host (never hardcoded)
|
||||||
|
|
||||||
|
This skill deliberately does **not** bake in a server hostname — this repo is mirrored to a public remote, so a real FQDN in the skill would leak into commits. Instead, resolve the deploy target into a `DEPLOY_HOST` shell variable that every `ssh` command below uses.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOST_FILE=".claude/skills/haproxy-manager-deploy/target-host.local"
|
||||||
|
DEPLOY_HOST="$(cat "$HOST_FILE" 2>/dev/null)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **If `$DEPLOY_HOST` is non-empty**, use it — that's the user's saved target. The file is gitignored, so the real hostname never lands in a commit.
|
||||||
|
- **If it's empty**, ask the user which server this deploy targets (e.g. production vs. staging) and what its hostname or SSH alias is. Then offer to save it so future deploys don't have to ask:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'the-host-they-gave.example' > "$HOST_FILE" # gitignored — safe to store the real FQDN here
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm `$DEPLOY_HOST` is set before running any `ssh` step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
[ -n "$DEPLOY_HOST" ] || echo "DEPLOY_HOST not set — ask the user for the target server"
|
||||||
|
```
|
||||||
|
|
||||||
|
All commands below assume the variable is set in the same shell session (`ssh root@"$DEPLOY_HOST" ...`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 0 — Confirm before pushing
|
||||||
|
|
||||||
|
If the user just said "deploy" or "ship the haproxy fix", confirm what's actually changing: a template, the Python manager, the coraza-spoa subdir (separate image, separate workflow), or a static asset. Look at `git status` and `git diff` and read the diff back to the user if it's non-trivial.
|
||||||
|
|
||||||
|
Anything that affects the customer-facing block path (e.g. `templates/hap_listener.tpl`, `errors/403-waf.html`) is **visible to every visitor on every site**. Authorization is per-deploy, not standing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Know which workflow your change triggers
|
||||||
|
|
||||||
|
- `build-push.yaml` builds `haproxy-manager-base:latest` (the main image). Triggered by changes anywhere outside `coraza-spoa/`.
|
||||||
|
- `build-push-coraza.yaml` builds `coraza-spoa:latest`. Triggered by changes inside `coraza-spoa/`.
|
||||||
|
- `mirror-base-image.yaml` is a scheduled job mirroring upstream base images; unrelated to feature deploys.
|
||||||
|
|
||||||
|
If you've changed both subtrees in one push, both workflows fire — note that the order they finish isn't guaranteed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — Beware the `/etc/haproxy` named volume shadow
|
||||||
|
|
||||||
|
If your change adds a NEW file that the running container needs (a baked-in asset, an errorfile, a new config snippet), **do not place it under `/etc/haproxy/` in the Dockerfile**. That path is a Docker named volume in deployed containers — image content only seeds the volume on first creation, so existing deployments will not see your new file even after a recreate.
|
||||||
|
|
||||||
|
Safe paths for baked-in assets:
|
||||||
|
- `/haproxy/...` (the image's WORKDIR — not volumed)
|
||||||
|
- Anywhere outside `/etc/haproxy`, `/etc/letsencrypt`
|
||||||
|
|
||||||
|
Reference the asset by absolute path from the haproxy config templates (e.g. `lf-file /haproxy/errors/403-waf.html`).
|
||||||
|
|
||||||
|
See `feedback-haproxy-named-volume` memory for the full pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3 — Commit + push
|
||||||
|
|
||||||
|
Standard commit format with trailing `Co-Authored-By:` line. Match the recent commit message style (`git log --oneline -5`). Stage files explicitly by name.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushing immediately triggers the Gitea Actions build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4 — Wait for the build
|
||||||
|
|
||||||
|
The Go build inside coraza-spoa takes ~2-3 minutes; the haproxy-manager-base build is faster (~1-2 min). Don't bother polling the runs UI — just pull on the target server until the digest changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@"$DEPLOY_HOST" 'until docker pull -q repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest 2>&1 | tail -1 | grep -qE "Image is up to date|Status: Downloaded"; do sleep 15; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
`-q` suppresses the noisy layer progress so the grep can match cleanly. If you started this command before the CI build finished, it'll loop until the new image lands; once the digest matches, it exits.
|
||||||
|
|
||||||
|
To confirm you got the new image, check the image-creation time vs your push:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@"$DEPLOY_HOST" 'docker images repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base --format "{{.CreatedSince}}"'
|
||||||
|
```
|
||||||
|
|
||||||
|
It should say "X minutes ago" matching the build wait, not "yesterday".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5 — Verify the new image has what you think it has, BEFORE recreating
|
||||||
|
|
||||||
|
The image is `gcr.io/distroless/static-debian12:nonroot`-based, no shell. To peek inside, run a one-shot with a sh entrypoint override (only works if you put one in the image — coraza-spoa is distroless and won't have sh; haproxy-manager-base is Python-based and does):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# haproxy-manager-base (has sh):
|
||||||
|
ssh root@"$DEPLOY_HOST" 'docker run --rm --entrypoint sh repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest -c "ls /haproxy/errors/ && head -5 /haproxy/errors/403-waf.html"'
|
||||||
|
|
||||||
|
# coraza-spoa (distroless, no sh) — use docker create + docker cp instead:
|
||||||
|
ssh root@"$DEPLOY_HOST" 'docker create --name _peek repo.anhonesthost.net/cloud-hosting-platform/coraza-spoa:latest && docker cp _peek:/etc/coraza/config.yaml - | tar xO; docker rm _peek'
|
||||||
|
```
|
||||||
|
|
||||||
|
This step exists because the CI build can succeed but ship the wrong file (wrong commit pulled, build cache issue, etc.). Catching it here is one step earlier than catching it from a customer report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6 — Recreate the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@"$DEPLOY_HOST" '/root/whp/scripts/container-manager.sh recreate haproxy-manager'
|
||||||
|
```
|
||||||
|
|
||||||
|
For coraza-spoa changes:
|
||||||
|
```bash
|
||||||
|
ssh root@"$DEPLOY_HOST" '/root/whp/scripts/container-manager.sh recreate coraza-spoa'
|
||||||
|
```
|
||||||
|
|
||||||
|
`container-manager.sh recreate` does: stop, remove, docker pull (idempotent if already pulled), start with the right flags from settings.json. **It reads `/docker/whp/settings.json` for things like `coraza_waf.mode`**, so if the user has toggled mode while you were building, the recreated container reflects the current setting — not whatever it was when you started.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7 — Verify the deploy
|
||||||
|
|
||||||
|
For haproxy-manager:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@"$DEPLOY_HOST" '
|
||||||
|
echo "=== container ==="
|
||||||
|
docker ps --filter name=haproxy-manager --format "image: {{.Image}} status: {{.Status}}"
|
||||||
|
echo "=== healthy ==="
|
||||||
|
docker inspect haproxy-manager --format "{{.State.Health.Status}}"
|
||||||
|
echo "=== haproxy config valid ==="
|
||||||
|
docker exec haproxy-manager haproxy -c -f /etc/haproxy/haproxy.cfg 2>&1 | tail -3
|
||||||
|
echo "=== new asset reachable inside container ==="
|
||||||
|
docker exec haproxy-manager ls -la /haproxy/errors/ 2>&1 | tail -3
|
||||||
|
echo "=== panel health ==="
|
||||||
|
curl -fsS -m 5 -o /dev/null -w "PANEL=%{http_code}\n" http://127.0.0.1:8000/health
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass criteria:
|
||||||
|
- Container status = healthy
|
||||||
|
- haproxy config validates (warnings OK, errors not)
|
||||||
|
- Your new asset (if any) is at the expected path inside the running container
|
||||||
|
- Panel returns 200
|
||||||
|
|
||||||
|
If any check fails, the change still went out — diagnose immediately. Don't say "deploy complete" before this clears.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8 — End-to-end test if customer-visible
|
||||||
|
|
||||||
|
If your change affects what a visitor sees (block pages, redirects, security responses), do a synthetic test that exercises the actual path. For WAF block-page changes, the recipe is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Inject a temporary ACL that forces the WAF deny path on a custom header,
|
||||||
|
# fire one request, observe the rendered response, then revert + reload.
|
||||||
|
ssh root@"$DEPLOY_HOST" '
|
||||||
|
docker exec haproxy-manager cp /etc/haproxy/haproxy.cfg /tmp/cfg-bak
|
||||||
|
docker exec haproxy-manager sh -c "sed -i \"/http-request send-spoe-group coraza coraza-req/a\\\\ http-request set-var(txn.coraza.action) str(deny) if { req.hdr(x-force-waf-block) -m str yes }\" /etc/haproxy/haproxy.cfg"
|
||||||
|
docker exec haproxy-manager sh -c "echo reload | socat stdio /tmp/haproxy-cli" >/dev/null
|
||||||
|
sleep 1
|
||||||
|
curl -sSk -D - -H "x-force-waf-block: yes" -H "Host: <live-vhost>" "https://localhost/" | head -40
|
||||||
|
# revert
|
||||||
|
docker exec haproxy-manager cp /tmp/cfg-bak /etc/haproxy/haproxy.cfg
|
||||||
|
docker exec haproxy-manager sh -c "echo reload | socat stdio /tmp/haproxy-cli" >/dev/null
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pick a real `<live-vhost>`.** The `Host:` header must match a domain currently served by this haproxy-manager, or the request won't route to the WAF path. Don't hardcode a customer hostname in this skill — pull a live one at test time (any entry from the panel's domain list, or `docker exec haproxy-manager ls /etc/letsencrypt/live`) and substitute it.
|
||||||
|
|
||||||
|
**The injection point matters.** Insert AFTER `http-request send-spoe-group coraza coraza-req`, because the SPOE call overwrites `txn.coraza.action` based on the real Coraza verdict — if you inject before it, your override is wiped.
|
||||||
|
|
||||||
|
**The reload mechanism matters.** Use `echo reload | socat stdio /tmp/haproxy-cli` — the container is python-based but doesn't have `kill` in PATH, and `docker kill --signal=HUP` signals the python manager (PID 1), not haproxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recovery hints
|
||||||
|
|
||||||
|
- **`docker pull` exits "Image is up to date" but your change isn't there** — CI hasn't finished yet. Check `https://repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base/actions` for in-progress runs.
|
||||||
|
- **Container recreates but new file is missing inside** — you put the file under `/etc/haproxy/` and the named volume shadows it. See Step 2. Move the file under `/haproxy/` (or another non-volumed path) and rebuild.
|
||||||
|
- **HAProxy `lf-file` page renders but CSS is broken / percentages stripped** — literal `%` in the file body must be doubled (`100%%`). HAProxy log-format expansion eats single `%`. See `haproxy-lf-file-percent-escape` memory.
|
||||||
|
- **Synthetic test returns 200 from gunicorn instead of the block page** — your test ACL is being overwritten by the SPOE call. Inject after `send-spoe-group`, not before.
|
||||||
|
- **`docker exec haproxy-manager kill -HUP 1` fails** — the python-based container doesn't have `kill` in PATH. Use the haproxy admin socket: `echo reload | socat stdio /tmp/haproxy-cli`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this skill is rigid
|
||||||
|
|
||||||
|
The pipeline is short, but the volume-shadowing trap and the SPOE-overwrite trap during testing each cost a 5-10 minute debugging detour during the session this skill was authored from. Both are silent failures — your change goes out, the container is healthy, and you only notice the bug when a customer report (or a careful synthetic test) surfaces it. The verification steps exist to catch them before that happens.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Build and push coraza-spoa
|
||||||
|
run-name: ${{ gitea.actor }} pushed a change to coraza-spoa/
|
||||||
|
|
||||||
|
# Triggers only on changes to the coraza-spoa subdirectory or this workflow
|
||||||
|
# file itself — keeps the main haproxy-manager-base build and the coraza-spoa
|
||||||
|
# build independent. workflow_dispatch lets us trigger manually after bumping
|
||||||
|
# the upstream coraza-spoa version pin.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'coraza-spoa/**'
|
||||||
|
- '.gitea/workflows/build-push-coraza.yaml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Build-and-Push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
# Mirror to GitHub Container Registry — see build-push.yaml for the
|
||||||
|
# secret/username convention.
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./coraza-spoa
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/coraza-spoa:latest
|
||||||
|
ghcr.io/shadowdao/coraza-spoa:latest
|
||||||
@@ -24,11 +24,38 @@ jobs:
|
|||||||
registry: repo.anhonesthost.net
|
registry: repo.anhonesthost.net
|
||||||
username: ${{ secrets.CI_USER }}
|
username: ${{ secrets.CI_USER }}
|
||||||
password: ${{ secrets.CI_TOKEN }}
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
# Second push target so the image is also available from GitHub Container
|
||||||
|
# Registry under the user's account. The PAT only needs write:packages
|
||||||
|
# (and read:packages if the package is private). Stored in Gitea as
|
||||||
|
# secrets.GHCR_TOKEN; username is the literal GitHub login.
|
||||||
|
- name: Login to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: shadowdao
|
||||||
|
password: ${{ secrets.GHCR_TOKEN }}
|
||||||
|
|
||||||
|
# Read the human-readable release version from the VERSION file so every
|
||||||
|
# build is pinnable for rollback (alongside the immutable git SHA). Bump
|
||||||
|
# VERSION (YYYY.MM.N) in the same commit as a release-worthy change.
|
||||||
|
- name: Read version
|
||||||
|
id: ver
|
||||||
|
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Build Image
|
- name: Build Image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.ver.outputs.version }}
|
||||||
|
# Three tags per registry: :latest (moving), :<version> (human-readable
|
||||||
|
# release), :<sha> (immutable, guaranteed-unique rollback target).
|
||||||
tags: |
|
tags: |
|
||||||
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest
|
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:${{ steps.ver.outputs.version }}
|
||||||
|
repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:${{ gitea.sha }}
|
||||||
|
ghcr.io/shadowdao/haproxy-manager-base:latest
|
||||||
|
ghcr.io/shadowdao/haproxy-manager-base:${{ steps.ver.outputs.version }}
|
||||||
|
ghcr.io/shadowdao/haproxy-manager-base:${{ gitea.sha }}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
name: Mirror base images
|
||||||
|
run-name: weekly base-image mirror
|
||||||
|
|
||||||
|
# Pulls each declared base image from upstream and re-pushes to the in-house
|
||||||
|
# registry, so any of our images that FROM these don't depend on docker.io's
|
||||||
|
# Cloudflare R2 blob storage being reachable. The 2026-05-12 Cloudflare
|
||||||
|
# incident motivated this for python:3.12-slim and again for golang:1.25
|
||||||
|
# when the coraza-spoa build hit the same blob-fetch failure.
|
||||||
|
#
|
||||||
|
# Adding a new mirror = add one entry to the matrix below. The destination
|
||||||
|
# tag is always cloud-hosting-platform/<image>:<tag>, matching upstream.
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays 06:00 UTC — outside customer peak hours and well before the
|
||||||
|
# typical Tuesday/Thursday push cycles. workflow_dispatch lets us trigger
|
||||||
|
# manually from the Gitea UI when upstream publishes patches.
|
||||||
|
- cron: '0 6 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Mirror-Base:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
# fail-fast=false so one image's upstream being down doesn't block the
|
||||||
|
# others from refreshing.
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
image:
|
||||||
|
- { src: 'docker.io/library/python:3.12-slim', dst_path: 'cloud-hosting-platform/python', tag: '3.12-slim' }
|
||||||
|
- { src: 'docker.io/library/golang:1.25', dst_path: 'cloud-hosting-platform/golang', tag: '1.25' }
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Login to in-house registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: repo.anhonesthost.net
|
||||||
|
username: ${{ secrets.CI_USER }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
|
- name: Pull, retag, push ${{ matrix.image.src }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="${{ matrix.image.src }}"
|
||||||
|
DST="repo.anhonesthost.net/${{ matrix.image.dst_path }}:${{ matrix.image.tag }}"
|
||||||
|
|
||||||
|
echo "::group::Pulling ${SRC}"
|
||||||
|
docker pull "${SRC}"
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
# Capture the upstream digest so the workflow log shows what we
|
||||||
|
# actually pushed. Helps diagnose "did the mirror really update"
|
||||||
|
# questions later.
|
||||||
|
SRC_DIGEST=$(docker image inspect "${SRC}" -f '{{index .RepoDigests 0}}')
|
||||||
|
echo "upstream digest: ${SRC_DIGEST}"
|
||||||
|
|
||||||
|
docker tag "${SRC}" "${DST}"
|
||||||
|
|
||||||
|
echo "::group::Pushing ${DST}"
|
||||||
|
docker push "${DST}"
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
# Sanity: the in-house tag should now resolve to the same content.
|
||||||
|
DST_DIGEST=$(docker image inspect "${DST}" -f '{{index .RepoDigests 0}}')
|
||||||
|
echo "mirror digest: ${DST_DIGEST}"
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Local-only deploy config (never commit real hostnames)
|
||||||
|
.claude/skills/haproxy-manager-deploy/target-host.local
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **API Testing**: `./scripts/test-api.sh` - Tests all API endpoints with optional authentication
|
||||||
|
- **Certificate Request Testing**: `./scripts/test-certificate-request.sh` - Tests certificate generation endpoints
|
||||||
|
- **Manual Testing**: Run `curl` commands against `http://localhost:8000` endpoints as shown in README.md
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
- **Docker Build**: `docker build -t haproxy-manager .`
|
||||||
|
- **Local Development**: `python haproxy_manager.py` (requires HAProxy, certbot, and dependencies installed)
|
||||||
|
- **Container Run**: See README.md for various docker run configurations
|
||||||
|
|
||||||
|
### Monitoring and Debugging
|
||||||
|
- **Error Monitoring**: `./scripts/monitor-errors.sh` - Monitor application error logs
|
||||||
|
- **External Monitoring**: `./scripts/monitor-errors-external.sh` - External monitoring script
|
||||||
|
- **Health Check**: `curl http://localhost:8000/health`
|
||||||
|
- **Log Files**:
|
||||||
|
- `/var/log/haproxy-manager.log` - General application logs
|
||||||
|
- `/var/log/haproxy-manager-errors.log` - Error logs for alerting
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **haproxy_manager.py** - Main Flask application providing:
|
||||||
|
- RESTful API for HAProxy configuration management
|
||||||
|
- SQLite database integration for domain/backend storage
|
||||||
|
- Let's Encrypt certificate automation
|
||||||
|
- HAProxy configuration generation from Jinja2 templates
|
||||||
|
- Optional API key authentication via `HAPROXY_API_KEY` environment variable
|
||||||
|
|
||||||
|
2. **Database Schema** - SQLite database with three main tables:
|
||||||
|
- `domains` - Domain configurations with SSL settings
|
||||||
|
- `backends` - Backend service definitions linked to domains
|
||||||
|
- `backend_servers` - Individual servers within backend groups
|
||||||
|
|
||||||
|
3. **Template System** - Jinja2 templates for HAProxy configuration generation:
|
||||||
|
- `hap_header.tpl` - Global HAProxy settings, defaults, and HTTP/2 tuning
|
||||||
|
- `hap_backend.tpl` - Backend server definitions
|
||||||
|
- `hap_listener.tpl` - Frontend listener configurations with rate limiting
|
||||||
|
- `hap_letsencrypt.tpl` - SSL certificate configurations
|
||||||
|
- `hap_security_tables.tpl` - Stats frontend and security stick tables
|
||||||
|
- Template override support for custom backend configurations
|
||||||
|
|
||||||
|
4. **Certificate Management** - Automated SSL certificate handling:
|
||||||
|
- Let's Encrypt integration with certbot
|
||||||
|
- Self-signed certificate fallback for development
|
||||||
|
- Certificate renewal automation via cron
|
||||||
|
- Certificate download endpoints for external services
|
||||||
|
|
||||||
|
### Configuration Flow
|
||||||
|
|
||||||
|
1. Domain added via `/api/domain` endpoint → Database updated
|
||||||
|
2. `generate_config()` function → Reads database, renders Jinja2 templates → Writes `/etc/haproxy/haproxy.cfg`
|
||||||
|
3. HAProxy reload via socket API (`/tmp/haproxy-cli`) or process restart
|
||||||
|
4. SSL certificate generation via Let's Encrypt or self-signed fallback
|
||||||
|
|
||||||
|
### Key Design Patterns
|
||||||
|
|
||||||
|
- **Template-driven configuration**: HAProxy config generated from modular Jinja2 templates
|
||||||
|
- **Database-backed state**: All configuration persisted in SQLite for reliability
|
||||||
|
- **API-first design**: All operations exposed via REST endpoints
|
||||||
|
- **Process monitoring**: Health checks and automatic HAProxy restart capabilities
|
||||||
|
- **Comprehensive logging**: Operation logging with error alerting support
|
||||||
|
|
||||||
|
### Authentication & Security
|
||||||
|
|
||||||
|
- Optional API key authentication controlled by `HAPROXY_API_KEY` environment variable
|
||||||
|
- All API endpoints (except `/health` and `/`) require Bearer token when API key is set
|
||||||
|
- Certificate private keys combined with certificates in HAProxy-compatible format
|
||||||
|
- Default backend page for unmatched domains instead of exposing HAProxy errors
|
||||||
|
|
||||||
|
### Rate Limiting & Connection Limits (hap_listener.tpl)
|
||||||
|
|
||||||
|
- **Stick table**: `type ip size 200k expire 10m` tracking `conn_cur`, `conn_rate(10s)`, `http_req_rate(10s)`, `http_err_rate(30s)`
|
||||||
|
- Tracks real client IP via `var(txn.real_ip)` to work correctly behind Cloudflare/proxies
|
||||||
|
- **Rate limit thresholds**:
|
||||||
|
- Tarpit at 3000 req/10s (300 req/s)
|
||||||
|
- Hard block (deny) at 5000 req/10s (500 req/s)
|
||||||
|
- Connection rate limit: 500/10s
|
||||||
|
- Concurrent connection limit: 500
|
||||||
|
- Error rate limit: 100/30s
|
||||||
|
- **Whitelist bypasses** (exempt from rate limits):
|
||||||
|
- `is_local` — RFC1918 private address ranges
|
||||||
|
- `is_trusted_ip` — source IPs listed in `trusted_ips.list`
|
||||||
|
- `is_whitelisted` — real IPs (from proxy headers) matched in `trusted_ips.map`
|
||||||
|
|
||||||
|
### Trusted IP Whitelist Files
|
||||||
|
|
||||||
|
- `trusted_ips.list` — Source IP whitelist for rate limit bypass (one CIDR/IP per line)
|
||||||
|
- `trusted_ips.map` — Real IP whitelist for proxy-header matching (format: `<IP> 1`)
|
||||||
|
- Both files are baked into the Docker image via `COPY` in the Dockerfile
|
||||||
|
- Ship as comment-only templates (no real IPs). Add trusted IPs locally and do **not** commit them — this repo is mirrored publicly. Entries persist in the `/etc/haproxy` named volume across recreates
|
||||||
|
|
||||||
|
### Timeout Hardening (hap_header.tpl)
|
||||||
|
|
||||||
|
- `timeout http-request`: 300s -> 30s (slowloris protection)
|
||||||
|
- `timeout connect`: 120s -> 10s
|
||||||
|
- `timeout client`: 10m -> 5m
|
||||||
|
- `timeout http-keep-alive`: 120s -> 30s
|
||||||
|
|
||||||
|
### HTTP/2 Protection (hap_header.tpl)
|
||||||
|
|
||||||
|
- `tune.h2.fe.max-total-streams 2000` — limits total streams per HTTP/2 connection
|
||||||
|
- `tune.h2.fe.glitches-threshold 50` — CVE-2023-44487 Rapid Reset protection
|
||||||
|
|
||||||
|
### Stats Frontend (hap_security_tables.tpl)
|
||||||
|
|
||||||
|
- HAProxy stats page bound to `127.0.0.1:8404` (localhost only, accessible inside container)
|
||||||
|
- Template: `templates/hap_security_tables.tpl`
|
||||||
|
|
||||||
|
### Deployment Context
|
||||||
|
|
||||||
|
- Designed to run as Docker container with persistent volumes for certificates and configurations
|
||||||
|
- Exposes ports 80 (HTTP), 443 (HTTPS), and 8000 (management API/UI)
|
||||||
|
- Stats page on port 8404 (localhost only inside container)
|
||||||
|
- Management interface on port 8000 should be firewall-protected in production
|
||||||
|
- Dockerfile HEALTHCHECK verifies both port 8000 (Flask API) and port 80 (HAProxy), with `start-period=60s` and `timeout=10s`
|
||||||
|
- Supports deployment on servers with git directory at `/root/whp` and web file sync via rsync to `/docker/whp/web/`
|
||||||
|
- HAProxy is version 3.0.11
|
||||||
+47
-6
@@ -1,15 +1,56 @@
|
|||||||
FROM python:3.12-slim
|
# Base image mirrored into the in-house registry to remove docker.io
|
||||||
RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot curl -y && apt clean && rm -rf /var/lib/apt/lists/*
|
# (Cloudflare R2) as a single point of failure for CI builds. The 2026-05-12
|
||||||
|
# Cloudflare incident took down docker.io blob pulls and broke this image's CI.
|
||||||
|
# Refresh procedure (run on a workstation that can reach docker.io, e.g.
|
||||||
|
# monthly or when Python patches drop):
|
||||||
|
# docker pull docker.io/library/python:3.12-slim
|
||||||
|
# docker tag docker.io/library/python:3.12-slim \
|
||||||
|
# repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
|
||||||
|
# docker push repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
|
||||||
|
# Future improvement: a scheduled Gitea Action that does the above automatically.
|
||||||
|
FROM repo.anhonesthost.net/cloud-hosting-platform/python:3.12-slim
|
||||||
|
|
||||||
|
# image.source is what ghcr.io uses to link the package to a GitHub repo
|
||||||
|
# sidebar; pointing at the public GitHub mirror enables that linking. The
|
||||||
|
# canonical source-of-truth git remote is still Gitea, but Gitea's registry
|
||||||
|
# doesn't consume this label, so there's no contention.
|
||||||
|
# Stamped from the VERSION file by CI (build-arg) so `docker inspect` reports
|
||||||
|
# what's running on any host. Defaults to "dev" for local/manual builds.
|
||||||
|
ARG VERSION=dev
|
||||||
|
LABEL org.opencontainers.image.title="haproxy-manager-base" \
|
||||||
|
org.opencontainers.image.description="HAProxy management API with Let's Encrypt automation, Coraza WAF integration, and template-driven config" \
|
||||||
|
org.opencontainers.image.source="https://github.com/shadowdao/haproxy-manager-base" \
|
||||||
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
RUN apt update -y && apt dist-upgrade -y && apt install socat haproxy cron certbot curl jq net-tools -y && apt clean && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /haproxy
|
WORKDIR /haproxy
|
||||||
COPY ./templates /haproxy/templates
|
COPY ./templates /haproxy/templates
|
||||||
COPY requirements.txt /haproxy/
|
COPY requirements.txt /haproxy/
|
||||||
COPY haproxy_manager.py /haproxy/
|
COPY haproxy_manager.py /haproxy/
|
||||||
COPY scripts /haproxy/scripts
|
COPY scripts /haproxy/scripts
|
||||||
|
COPY trusted_ips.list /etc/haproxy/trusted_ips.list
|
||||||
|
COPY trusted_ips.map /etc/haproxy/trusted_ips.map
|
||||||
|
# /etc/haproxy is a named volume in deployed containers, so baked-in files
|
||||||
|
# under that path get shadowed by the volume on existing deployments.
|
||||||
|
# Place errorfiles outside the volumed path; the HAProxy config references
|
||||||
|
# them by absolute path.
|
||||||
|
COPY errors /haproxy/errors
|
||||||
RUN chmod +x /haproxy/scripts/*
|
RUN chmod +x /haproxy/scripts/*
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
RUN echo "0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(43200))' && certbot -q renew --no-random-sleep-on-renew" > /var/spool/cron/crontabs/root
|
# Create log directories
|
||||||
EXPOSE 80 443 8000
|
RUN mkdir -p /var/log && touch /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||||
|
RUN chmod 755 /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||||
|
# Set up cron for certificate renewal with proper permissions and environment
|
||||||
|
RUN mkdir -p /var/spool/cron/crontabs && \
|
||||||
|
echo 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' > /var/spool/cron/crontabs/root && \
|
||||||
|
echo '0 */12 * * * /haproxy/scripts/renew-certificates.sh >> /var/log/haproxy-manager.log 2>&1' >> /var/spool/cron/crontabs/root && \
|
||||||
|
chmod 600 /var/spool/cron/crontabs/root && \
|
||||||
|
chown root:crontab /var/spool/cron/crontabs/root
|
||||||
|
# 443/udp carries HTTP/3 (QUIC). EXPOSE is documentation only — the container
|
||||||
|
# must still be run with `-p 443:443/udp` for the UDP listener to be reachable.
|
||||||
|
EXPOSE 80 443 443/udp 8000
|
||||||
# Add health check
|
# Add health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
CMD curl -sf --max-time 5 http://localhost:8000/health && curl -s --max-time 5 -o /dev/null http://localhost/ || exit 1
|
||||||
CMD ["/haproxy/scripts/start-up.sh"]
|
CMD ["/haproxy/scripts/start-up.sh"]
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
# IP Blocking API Documentation
|
||||||
|
|
||||||
|
This document describes the IP blocking functionality added to HAProxy Manager, which allows WHP (Web Hosting Platform) to manage blocked IP addresses through the API.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The IP blocking feature allows administrators to:
|
||||||
|
- Block specific IP addresses or CIDR ranges from accessing any sites managed by HAProxy
|
||||||
|
- Unblock previously blocked IP addresses or CIDR ranges
|
||||||
|
- View all currently blocked IP addresses and CIDR ranges
|
||||||
|
- Track who blocked an IP/CIDR and when
|
||||||
|
|
||||||
|
When an IP is blocked (or falls within a blocked CIDR range), visitors from that IP address will receive a 403 Forbidden response.
|
||||||
|
|
||||||
|
### CIDR Range Support
|
||||||
|
|
||||||
|
The IP blocking system supports CIDR notation for blocking entire network ranges:
|
||||||
|
- **Single IP**: `192.168.1.100` (blocks only this IP)
|
||||||
|
- **CIDR Range**: `192.168.1.0/24` (blocks 256 IPs from 192.168.1.0 to 192.168.1.255)
|
||||||
|
- **Common CIDR Masks**:
|
||||||
|
- `/32` - Single IP (1 address)
|
||||||
|
- `/24` - Standard subnet (256 addresses)
|
||||||
|
- `/16` - Large network (65,536 addresses)
|
||||||
|
- `/8` - Very large network (16,777,216 addresses)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Runtime IP blocking**: Changes take effect immediately without HAProxy restarts
|
||||||
|
- **Map file based**: No ACL word limits, supports unlimited blocked IPs
|
||||||
|
- **Safe configuration management**: Automatic validation and rollback on failures
|
||||||
|
- **Runtime map synchronization**: Keep database and HAProxy runtime in sync
|
||||||
|
- **Audit logging**: All operations are logged for monitoring and compliance
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
All IP blocking endpoints require API key authentication when `HAPROXY_API_KEY` is set:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Get All Blocked IPs
|
||||||
|
|
||||||
|
Retrieve a list of all currently blocked IP addresses.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /api/blocked-ips`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"reason": "Suspicious activity detected",
|
||||||
|
"blocked_at": "2024-01-15 10:30:00",
|
||||||
|
"blocked_by": "WHP Admin Panel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"ip_address": "10.0.0.50",
|
||||||
|
"reason": "Brute force attempts",
|
||||||
|
"blocked_at": "2024-01-15 11:45:00",
|
||||||
|
"blocked_by": "Security System"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X GET http://localhost:8000/api/blocked-ips \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Block an IP Address
|
||||||
|
|
||||||
|
Add an IP address to the blocked list.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/blocked-ips`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"reason": "Suspicious activity detected",
|
||||||
|
"blocked_by": "WHP Admin Panel"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `ip_address` (required): The IP address or CIDR range to block (e.g., "192.168.1.100" or "192.168.1.0/24")
|
||||||
|
- `reason` (optional): Reason for blocking (default: "No reason provided")
|
||||||
|
- `blocked_by` (optional): Who/what initiated the block (default: "API")
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"blocked_ip_id": 1,
|
||||||
|
"message": "IP 192.168.1.100 has been blocked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request`: IP address is missing
|
||||||
|
- `409 Conflict`: IP address is already blocked
|
||||||
|
- `500 Internal Server Error`: Configuration generation failed
|
||||||
|
|
||||||
|
**Example Request (Single IP):**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/blocked-ips \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"reason": "Multiple failed login attempts",
|
||||||
|
"blocked_by": "WHP Security Module"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request (CIDR Range):**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/blocked-ips \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"ip_address": "192.168.1.0/24",
|
||||||
|
"reason": "DDoS attack from compromised ISP",
|
||||||
|
"blocked_by": "WHP Security Module"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Unblock an IP Address or CIDR Range
|
||||||
|
|
||||||
|
Remove an IP address or CIDR range from the blocked list.
|
||||||
|
|
||||||
|
**Endpoint:** `DELETE /api/blocked-ips`
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ip_address": "192.168.1.100"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `ip_address` (required): The IP address or CIDR range to unblock (must match exactly as it was blocked)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "IP 192.168.1.100 has been unblocked"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request`: IP address is missing
|
||||||
|
- `404 Not Found`: IP address not found in blocked list
|
||||||
|
- `500 Internal Server Error`: Configuration generation failed
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:8000/api/blocked-ips \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ip_address": "192.168.1.100"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with WHP
|
||||||
|
|
||||||
|
### PHP Integration Example
|
||||||
|
|
||||||
|
Here's how to integrate the IP blocking API into WHP using PHP:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
class HAProxyIPBlocker {
|
||||||
|
private $apiUrl;
|
||||||
|
private $apiKey;
|
||||||
|
|
||||||
|
public function __construct($apiUrl, $apiKey) {
|
||||||
|
$this->apiUrl = rtrim($apiUrl, '/');
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocked IPs
|
||||||
|
*/
|
||||||
|
public function getBlockedIPs() {
|
||||||
|
return $this->makeRequest('GET', '/api/blocked-ips');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block an IP address
|
||||||
|
*/
|
||||||
|
public function blockIP($ipAddress, $reason = null, $blockedBy = 'WHP Control Panel') {
|
||||||
|
$data = [
|
||||||
|
'ip_address' => $ipAddress,
|
||||||
|
'reason' => $reason ?: 'Blocked via WHP Control Panel',
|
||||||
|
'blocked_by' => $blockedBy
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->makeRequest('POST', '/api/blocked-ips', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unblock an IP address
|
||||||
|
*/
|
||||||
|
public function unblockIP($ipAddress) {
|
||||||
|
$data = ['ip_address' => $ipAddress];
|
||||||
|
return $this->makeRequest('DELETE', '/api/blocked-ips', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request
|
||||||
|
*/
|
||||||
|
private function makeRequest($method, $endpoint, $data = null) {
|
||||||
|
$url = $this->apiUrl . $endpoint;
|
||||||
|
|
||||||
|
$headers = [
|
||||||
|
'Authorization: Bearer ' . $this->apiKey,
|
||||||
|
'Content-Type: application/json'
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init();
|
||||||
|
curl_setopt($ch, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
|
||||||
|
if ($data) {
|
||||||
|
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
$result = json_decode($response, true);
|
||||||
|
|
||||||
|
if ($httpCode >= 200 && $httpCode < 300) {
|
||||||
|
return ['success' => true, 'data' => $result];
|
||||||
|
} else {
|
||||||
|
return ['success' => false, 'error' => $result['message'] ?? 'Unknown error', 'code' => $httpCode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example:
|
||||||
|
$haproxyBlocker = new HAProxyIPBlocker('http://haproxy-manager:8000', 'your-api-key-here');
|
||||||
|
|
||||||
|
// Block an IP
|
||||||
|
$result = $haproxyBlocker->blockIP('192.168.1.100', 'Spam detection', 'WHP Anti-Spam Module');
|
||||||
|
if ($result['success']) {
|
||||||
|
echo "IP blocked successfully: " . $result['data']['message'];
|
||||||
|
} else {
|
||||||
|
echo "Error: " . $result['error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all blocked IPs
|
||||||
|
$blockedIPs = $haproxyBlocker->getBlockedIPs();
|
||||||
|
if ($blockedIPs['success']) {
|
||||||
|
foreach ($blockedIPs['data'] as $ip) {
|
||||||
|
echo "Blocked IP: {$ip['ip_address']} - Reason: {$ip['reason']}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unblock an IP
|
||||||
|
$result = $haproxyBlocker->unblockIP('192.168.1.100');
|
||||||
|
if ($result['success']) {
|
||||||
|
echo "IP unblocked successfully";
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
```
|
||||||
|
|
||||||
|
### WHP Control Panel Integration
|
||||||
|
|
||||||
|
To add IP blocking management to the WHP control panel:
|
||||||
|
|
||||||
|
1. **Create a management interface page** (`/admin/ip-blocking.php`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
// Initialize the HAProxy IP Blocker
|
||||||
|
$haproxyBlocker = new HAProxyIPBlocker(
|
||||||
|
getenv('HAPROXY_MANAGER_URL') ?: 'http://haproxy-manager:8000',
|
||||||
|
getenv('HAPROXY_API_KEY')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle form submissions
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (isset($_POST['action'])) {
|
||||||
|
switch ($_POST['action']) {
|
||||||
|
case 'block':
|
||||||
|
$ip = filter_var($_POST['ip_address'], FILTER_VALIDATE_IP);
|
||||||
|
if ($ip) {
|
||||||
|
$result = $haproxyBlocker->blockIP(
|
||||||
|
$ip,
|
||||||
|
$_POST['reason'] ?? '',
|
||||||
|
$_SESSION['admin_username'] ?? 'WHP Admin'
|
||||||
|
);
|
||||||
|
$message = $result['success']
|
||||||
|
? "IP {$ip} has been blocked"
|
||||||
|
: "Error: " . $result['error'];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'unblock':
|
||||||
|
$ip = filter_var($_POST['ip_address'], FILTER_VALIDATE_IP);
|
||||||
|
if ($ip) {
|
||||||
|
$result = $haproxyBlocker->unblockIP($ip);
|
||||||
|
$message = $result['success']
|
||||||
|
? "IP {$ip} has been unblocked"
|
||||||
|
: "Error: " . $result['error'];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current blocked IPs
|
||||||
|
$blockedIPs = $haproxyBlocker->getBlockedIPs();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>IP Blocking Management - WHP</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>IP Blocking Management</h1>
|
||||||
|
|
||||||
|
<?php if (isset($message)): ?>
|
||||||
|
<div class="alert"><?= htmlspecialchars($message) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Block IP Form -->
|
||||||
|
<h2>Block an IP Address</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="block">
|
||||||
|
<label>IP Address: <input type="text" name="ip_address" required pattern="\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"></label><br>
|
||||||
|
<label>Reason: <input type="text" name="reason" size="50"></label><br>
|
||||||
|
<button type="submit">Block IP</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Currently Blocked IPs -->
|
||||||
|
<h2>Currently Blocked IPs</h2>
|
||||||
|
<table border="1">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Reason</th>
|
||||||
|
<th>Blocked By</th>
|
||||||
|
<th>Blocked At</th>
|
||||||
|
<th>Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php if ($blockedIPs['success']): ?>
|
||||||
|
<?php foreach ($blockedIPs['data'] as $ip): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($ip['ip_address']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($ip['reason']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($ip['blocked_by']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($ip['blocked_at']) ?></td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" style="display:inline">
|
||||||
|
<input type="hidden" name="action" value="unblock">
|
||||||
|
<input type="hidden" name="ip_address" value="<?= htmlspecialchars($ip['ip_address']) ?>">
|
||||||
|
<button type="submit">Unblock</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Environment Configuration**
|
||||||
|
|
||||||
|
Add these environment variables to your WHP configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# HAProxy Manager API Configuration
|
||||||
|
HAPROXY_MANAGER_URL=http://haproxy-manager:8000
|
||||||
|
HAPROXY_API_KEY=your-secure-api-key-here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Automatic Blocking Integration**
|
||||||
|
|
||||||
|
You can automatically block IPs based on certain criteria:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Example: Auto-block after multiple failed login attempts
|
||||||
|
function handleFailedLogin($username, $ipAddress) {
|
||||||
|
global $haproxyBlocker;
|
||||||
|
|
||||||
|
// Track failed attempts (implement your own logic)
|
||||||
|
$failedAttempts = getFailedAttempts($ipAddress);
|
||||||
|
|
||||||
|
if ($failedAttempts >= 5) {
|
||||||
|
$haproxyBlocker->blockIP(
|
||||||
|
$ipAddress,
|
||||||
|
"5+ failed login attempts for user: {$username}",
|
||||||
|
"WHP Security System"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log the blocking action
|
||||||
|
error_log("Auto-blocked IP {$ipAddress} due to multiple failed login attempts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **Database Storage**: Blocked IPs are stored in the SQLite database table `blocked_ips`
|
||||||
|
2. **HAProxy Configuration**: When an IP is blocked/unblocked, the HAProxy configuration is regenerated
|
||||||
|
3. **ACL Rules**: HAProxy uses ACL rules to check if a source IP is in the blocked list
|
||||||
|
4. **Blocked Page**: Blocked IPs are served a custom "Access Denied" page via the default backend
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
To test the IP blocking functionality:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Block your test IP
|
||||||
|
curl -X POST http://localhost:8000/api/blocked-ips \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ip_address": "YOUR_TEST_IP", "reason": "Testing"}'
|
||||||
|
|
||||||
|
# Try to access a site (you should see the blocked page)
|
||||||
|
curl -H "X-Forwarded-For: YOUR_TEST_IP" http://localhost
|
||||||
|
|
||||||
|
# Unblock the IP
|
||||||
|
curl -X DELETE http://localhost:8000/api/blocked-ips \
|
||||||
|
-H "Authorization: Bearer your-api-key" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"ip_address": "YOUR_TEST_IP"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- IP blocks are applied globally to all domains managed by HAProxy
|
||||||
|
- Changes take effect immediately without HAProxy restarts (runtime updates)
|
||||||
|
- Blocked IPs are persistent across HAProxy restarts (stored in database and map file)
|
||||||
|
- Map files support unlimited IPs (no ACL word limit restrictions)
|
||||||
|
- Consider implementing rate limiting on the API endpoints to prevent abuse
|
||||||
|
|
||||||
|
## New API Endpoints (Map File Era)
|
||||||
|
|
||||||
|
### 4. Safe Configuration Reload
|
||||||
|
|
||||||
|
Safely reload the HAProxy configuration with validation and automatic rollback.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/config/reload`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "HAProxy configuration reloaded safely"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": "Safe reload failed: Config validation failed: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/config/reload \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Sync Runtime Map
|
||||||
|
|
||||||
|
Synchronize blocked IPs from database to HAProxy runtime map.
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/blocked-ips/sync`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Synced 150/150 IPs to runtime map",
|
||||||
|
"total_ips": 150,
|
||||||
|
"synced_ips": 150
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/blocked-ips/sync \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runtime Map Commands
|
||||||
|
|
||||||
|
For advanced users, you can interact directly with HAProxy's runtime API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add IP to runtime (immediate effect)
|
||||||
|
echo "add map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock
|
||||||
|
|
||||||
|
# Remove IP from runtime
|
||||||
|
echo "del map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock
|
||||||
|
|
||||||
|
# Clear all blocked IPs from runtime
|
||||||
|
echo "clear map #0" | socat stdio /var/run/haproxy.sock
|
||||||
|
|
||||||
|
# Show all runtime map entries
|
||||||
|
echo "show map #0" | socat stdio /var/run/haproxy.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration from ACL Method
|
||||||
|
|
||||||
|
If you're upgrading from the old ACL-based method:
|
||||||
|
|
||||||
|
1. **Automatic**: Just update the HAProxy Manager code - it will automatically migrate
|
||||||
|
2. **Validation**: The new system includes automatic config validation and rollback
|
||||||
|
3. **No Downtime**: Runtime updates mean no service interruptions
|
||||||
|
4. **Scalable**: No more 64 IP limit - handle thousands of blocked IPs
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# HAProxy Manager Migration Guide: ACL to Map Files
|
||||||
|
|
||||||
|
## Critical Issue Fixed
|
||||||
|
|
||||||
|
HAProxy has a **64 word limit per ACL line**, which caused the following error when too many IPs were blocked:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ALERT] (1485) : config : parsing [/etc/haproxy/haproxy.cfg:58]: too many words, truncating after word 64, position 880: <197.5.145.73>.
|
||||||
|
[ALERT] (1485) : config : parsing [/etc/haproxy/haproxy.cfg:61] : error detected while parsing an 'http-request set-path' condition : no such ACL : 'is_blocked'.
|
||||||
|
```
|
||||||
|
|
||||||
|
This caused HAProxy to drop traffic for **ALL sites**, creating a critical outage.
|
||||||
|
|
||||||
|
## Solution: Map Files
|
||||||
|
|
||||||
|
We've migrated from ACL-based IP blocking to **HAProxy map files** which:
|
||||||
|
|
||||||
|
✅ **No word limits** - handle millions of IPs
|
||||||
|
✅ **Runtime updates** - no config reloads needed
|
||||||
|
✅ **Better performance** - hash table lookups instead of linear search
|
||||||
|
✅ **Config validation** - automatic rollback on failures
|
||||||
|
✅ **Backup/restore** - automatic backup before any changes
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
### Before (Problematic ACL Method)
|
||||||
|
```haproxy
|
||||||
|
# In haproxy.cfg template
|
||||||
|
acl is_blocked src 192.168.1.1 192.168.1.2 ... (64 word limit!)
|
||||||
|
http-request set-path /blocked-ip if is_blocked
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Map File Method)
|
||||||
|
```haproxy
|
||||||
|
# In haproxy.cfg
|
||||||
|
http-request deny status 403 if { src -f /etc/haproxy/blocked_ips.map }
|
||||||
|
|
||||||
|
# In /etc/haproxy/blocked_ips.map
|
||||||
|
192.168.1.1
|
||||||
|
192.168.1.2
|
||||||
|
64.235.37.112
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
### 1. Safe Configuration Management
|
||||||
|
- **Automatic backups** before any changes
|
||||||
|
- **Configuration validation** before applying
|
||||||
|
- **Automatic rollback** if validation fails
|
||||||
|
- **Graceful error handling**
|
||||||
|
|
||||||
|
### 2. Runtime IP Management
|
||||||
|
```bash
|
||||||
|
# Add IP without reload (immediate effect)
|
||||||
|
echo "add map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock
|
||||||
|
|
||||||
|
# Remove IP without reload
|
||||||
|
echo "del map #0 192.168.1.100" | socat stdio /var/run/haproxy.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. New API Endpoints
|
||||||
|
|
||||||
|
#### Safe Config Reload
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/config/reload \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sync Runtime Maps
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/blocked-ips/sync \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Process
|
||||||
|
|
||||||
|
### Automatic Migration
|
||||||
|
The system automatically:
|
||||||
|
1. Creates `/etc/haproxy/blocked_ips.map` from database
|
||||||
|
2. Updates HAProxy config to use map files
|
||||||
|
3. Validates new configuration
|
||||||
|
4. Creates backups before applying changes
|
||||||
|
|
||||||
|
### Manual Migration (if needed)
|
||||||
|
```bash
|
||||||
|
# 1. Stop HAProxy manager
|
||||||
|
systemctl stop haproxy-manager
|
||||||
|
|
||||||
|
# 2. Backup current config
|
||||||
|
cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.backup
|
||||||
|
|
||||||
|
# 3. Update HAProxy manager code
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 4. Start HAProxy manager
|
||||||
|
systemctl start haproxy-manager
|
||||||
|
|
||||||
|
# 5. Trigger config regeneration
|
||||||
|
curl -X POST http://localhost:8000/api/config/reload \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If issues occur, the system automatically:
|
||||||
|
|
||||||
|
1. **Restores backup configuration**
|
||||||
|
2. **Reloads HAProxy with known-good config**
|
||||||
|
3. **Logs all errors for debugging**
|
||||||
|
|
||||||
|
Manual rollback if needed:
|
||||||
|
```bash
|
||||||
|
# Restore backup
|
||||||
|
cp /etc/haproxy/haproxy.cfg.backup /etc/haproxy/haproxy.cfg
|
||||||
|
systemctl reload haproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Benefits
|
||||||
|
|
||||||
|
| Feature | Old ACL Method | New Map Method |
|
||||||
|
|---------|---------------|----------------|
|
||||||
|
| **IP Limit** | 64 IPs max | Unlimited |
|
||||||
|
| **Updates** | Full reload required | Runtime updates |
|
||||||
|
| **Lookup Speed** | O(n) linear | O(1) hash table |
|
||||||
|
| **Memory Usage** | High (all in config) | Low (external file) |
|
||||||
|
| **Restart Required** | Yes | No |
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
Check HAProxy manager logs for any issues:
|
||||||
|
```bash
|
||||||
|
tail -f /var/log/haproxy-manager.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Key log entries to watch for:
|
||||||
|
- `Configuration validation passed/failed`
|
||||||
|
- `Backup created/restored`
|
||||||
|
- `Runtime map updated`
|
||||||
|
- `Safe reload completed`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Map File Not Found
|
||||||
|
```bash
|
||||||
|
# Check if map file exists
|
||||||
|
ls -la /etc/haproxy/blocked_ips.map
|
||||||
|
|
||||||
|
# Manually create if missing
|
||||||
|
curl -X POST http://localhost:8000/api/blocked-ips/sync \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Updates Not Working
|
||||||
|
```bash
|
||||||
|
# Check HAProxy stats socket
|
||||||
|
ls -la /var/run/haproxy.sock /tmp/haproxy-cli
|
||||||
|
|
||||||
|
# Test socket connection
|
||||||
|
echo "show info" | socat stdio /var/run/haproxy.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config Validation Failures
|
||||||
|
The system automatically:
|
||||||
|
1. Creates backup before changes
|
||||||
|
2. Validates new config
|
||||||
|
3. Restores backup if validation fails
|
||||||
|
4. Logs detailed error messages
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Geographic IP blocking** using map files
|
||||||
|
- **Rate limiting integration**
|
||||||
|
- **Automatic threat feed integration**
|
||||||
|
- **API rate limiting per client**
|
||||||
|
|
||||||
|
## HAProxy Version Compatibility
|
||||||
|
|
||||||
|
Map files require **HAProxy 1.6+** (released December 2015)
|
||||||
|
- ✅ HAProxy 1.6+ (Map files supported)
|
||||||
|
- ❌ HAProxy 1.5 and older (Not supported)
|
||||||
|
|
||||||
|
Check your version:
|
||||||
|
```bash
|
||||||
|
haproxy -v
|
||||||
|
```
|
||||||
@@ -5,8 +5,13 @@ A Flask-based API service for managing HAProxy configurations with dynamic SSL c
|
|||||||
|
|
||||||
To run the container:
|
To run the container:
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 80:80 -p 443:443 -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy --name haproxy-manager repo.anhonesthost.net/cloud-hosting-platform/haproxy-manager-base:latest
|
# Without API key authentication (default)
|
||||||
|
docker run -d -p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy --name haproxy-manager your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest
|
||||||
|
|
||||||
|
# With API key authentication (recommended for production)
|
||||||
|
docker run -d -p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 -v lets-encrypt:/etc/letsencrypt -v haproxy:/etc/haproxy -e HAPROXY_API_KEY=your-secure-api-key-here --name haproxy-manager your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- RESTful API for HAProxy configuration management
|
- RESTful API for HAProxy configuration management
|
||||||
@@ -18,6 +23,26 @@ docker run -d -p 80:80 -p 443:443 -p 8000:8000 -v lets-encrypt:/etc/letsencrypt
|
|||||||
- Template override support for custom backend configurations
|
- Template override support for custom backend configurations
|
||||||
- Process monitoring and auto-restart capabilities
|
- Process monitoring and auto-restart capabilities
|
||||||
- Socket-based HAProxy runtime API integration
|
- Socket-based HAProxy runtime API integration
|
||||||
|
- **NEW**: API key authentication for secure access
|
||||||
|
- **NEW**: Certificate renewal API endpoint
|
||||||
|
- **NEW**: Certificate download endpoints for other services
|
||||||
|
- **NEW**: Comprehensive error logging and alerting system
|
||||||
|
- **NEW**: Certificate status monitoring with expiration dates
|
||||||
|
- **NEW**: Default backend page for unmatched domains
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### API Key Authentication
|
||||||
|
|
||||||
|
When the `HAPROXY_API_KEY` environment variable is set, all API endpoints (except `/health` and `/`) require authentication using a Bearer token:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example API call with authentication
|
||||||
|
curl -H "Authorization: Bearer your-secure-api-key-here" \
|
||||||
|
http://localhost:8000/api/domains
|
||||||
|
```
|
||||||
|
|
||||||
|
If no API key is set, the service runs without authentication (useful for development).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -34,6 +59,17 @@ The HAProxy Manager includes a web-based user interface accessible at port 8000,
|
|||||||
- Domain and backend server management interface
|
- Domain and backend server management interface
|
||||||
- SSL certificate status monitoring
|
- SSL certificate status monitoring
|
||||||
|
|
||||||
|
__Do Not Expose port 8000 to the open internet__
|
||||||
|
If you need to have it exposed to the internet, restrict it to an IP Address via IPTABLES or other firewalls.
|
||||||
|
```bash
|
||||||
|
# Allow access from the specific IP address (replace 192.168.1.100 with your IP)
|
||||||
|
iptables -A INPUT -p tcp --dport 8000 -s {YOUR_PUBLIC_IP} -j ACCEPT
|
||||||
|
|
||||||
|
# Drop all other connections to port 8000
|
||||||
|
iptables -A INPUT -p tcp --dport 8000 -j DROP
|
||||||
|
```
|
||||||
|
If you need to be able to access the web interface from multiple locations, I recommend putting it behind an authenticated Proxy like Authentik
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Health Check
|
### Health Check
|
||||||
@@ -50,11 +86,32 @@ GET /health
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Get Domains
|
||||||
|
Retrieve all configured domains and their backend information.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/domains
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"domain": "example.com",
|
||||||
|
"ssl_enabled": 1,
|
||||||
|
"ssl_cert_path": "/etc/haproxy/certs/example.com.pem",
|
||||||
|
"template_override": null,
|
||||||
|
"backend_name": "example_backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### Add Domain
|
### Add Domain
|
||||||
Add a new domain with backend servers configuration.
|
Add a new domain with backend servers configuration.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/domain
|
POST /api/domain
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -89,6 +146,7 @@ Request and configure SSL certificate for a domain using Let's Encrypt.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
POST /api/ssl
|
POST /api/ssl
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -106,6 +164,7 @@ Remove a domain and its associated backend configuration.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
DELETE /api/domain
|
DELETE /api/domain
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -118,3 +177,281 @@ Content-Type: application/json
|
|||||||
"message": "Domain configuration removed"
|
"message": "Domain configuration removed"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Regenerate Configuration
|
||||||
|
Regenerate HAProxy configuration from database.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/regenerate
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reload HAProxy
|
||||||
|
Reload HAProxy configuration without restart.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/reload
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## New Certificate Management Endpoints
|
||||||
|
|
||||||
|
### Request Certificate Generation
|
||||||
|
Request certificate generation for one or more domains.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/certificates/request
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"domains": ["example.com", "api.example.com"],
|
||||||
|
"force_renewal": false,
|
||||||
|
"include_www": true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "completed",
|
||||||
|
"summary": {
|
||||||
|
"total": 2,
|
||||||
|
"successful": 2,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"domain": "example.com",
|
||||||
|
"status": "success",
|
||||||
|
"message": "Certificate obtained successfully",
|
||||||
|
"cert_path": "/etc/haproxy/certs/example.com.pem",
|
||||||
|
"domains_covered": ["example.com", "www.example.com"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"domain": "api.example.com",
|
||||||
|
"status": "success",
|
||||||
|
"message": "Certificate obtained successfully",
|
||||||
|
"cert_path": "/etc/haproxy/certs/api.example.com.pem",
|
||||||
|
"domains_covered": ["api.example.com"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renew All Certificates
|
||||||
|
Trigger renewal of all Let's Encrypt certificates and reload HAProxy.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/certificates/renew
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"message": "Certificates renewed and HAProxy reloaded"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Certificate Status
|
||||||
|
Get status of all certificates including expiration dates.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/certificates/status
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Response
|
||||||
|
{
|
||||||
|
"certificates": [
|
||||||
|
{
|
||||||
|
"domain": "example.com",
|
||||||
|
"ssl_enabled": true,
|
||||||
|
"cert_path": "/etc/haproxy/certs/example.com.pem",
|
||||||
|
"expires": "2024-12-31T23:59:59",
|
||||||
|
"days_until_expiry": 45
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download Certificate Files
|
||||||
|
Download certificate files for use by other services.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download combined certificate (cert + key)
|
||||||
|
GET /api/certificates/example.com/download
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Download private key only
|
||||||
|
GET /api/certificates/example.com/key
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
|
||||||
|
# Download certificate only (no private key)
|
||||||
|
GET /api/certificates/example.com/cert
|
||||||
|
Authorization: Bearer your-api-key
|
||||||
|
```
|
||||||
|
|
||||||
|
## Certificate Renewal
|
||||||
|
|
||||||
|
The HAProxy Manager includes automatic certificate renewal with multiple scheduling options:
|
||||||
|
|
||||||
|
### Automatic Renewal (Container-based)
|
||||||
|
By default, a cron job runs inside the container every 12 hours to check and renew certificates:
|
||||||
|
- Runs at minute 0 of every 12th hour (12:00 AM, 12:00 PM)
|
||||||
|
- Automatically reloads HAProxy if certificates are renewed
|
||||||
|
- Logs all renewal attempts to `/var/log/haproxy-manager.log`
|
||||||
|
- Errors logged to `/var/log/haproxy-manager-errors.log`
|
||||||
|
|
||||||
|
### Manual Renewal via API
|
||||||
|
Trigger certificate renewal manually using the API:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/certificates/renew \
|
||||||
|
-H "Authorization: Bearer your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host-side Renewal (Recommended for Production)
|
||||||
|
For more control over scheduling, run renewals from the host machine using the provided script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make the script executable
|
||||||
|
chmod +x scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# Add to host crontab (edit with: crontab -e)
|
||||||
|
0 */12 * * * /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# Or run manually
|
||||||
|
./scripts/host-renew-certificates.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The host-side script:
|
||||||
|
- Executes the renewal process inside the running container
|
||||||
|
- Maintains separate host-side logs at `/var/log/haproxy-manager-host-renewal.log`
|
||||||
|
- Automatically detects if the container is running
|
||||||
|
- Supports custom container names via `CONTAINER_NAME` environment variable
|
||||||
|
|
||||||
|
See [scripts/host-crontab-example.txt](scripts/host-crontab-example.txt) for more crontab configuration examples.
|
||||||
|
|
||||||
|
### Renewal Script Features
|
||||||
|
The renewal script ([scripts/renew-certificates.sh](scripts/renew-certificates.sh)) includes:
|
||||||
|
- Comprehensive logging with timestamps
|
||||||
|
- Retry logic for HAProxy reload (3 attempts with 5-second delays)
|
||||||
|
- HAProxy socket health checks before reload
|
||||||
|
- Proper error handling and exit codes
|
||||||
|
- Detection of whether certificates actually needed renewal
|
||||||
|
|
||||||
|
## Logging and Monitoring
|
||||||
|
|
||||||
|
The HAProxy Manager includes comprehensive logging and error tracking:
|
||||||
|
|
||||||
|
### Log Files
|
||||||
|
- `/var/log/haproxy-manager.log` - General application logs
|
||||||
|
- `/var/log/haproxy-manager-errors.log` - Error logs for alerting
|
||||||
|
- `/var/log/haproxy-manager-host-renewal.log` - Host-side renewal logs (when using host script)
|
||||||
|
|
||||||
|
### Logged Operations
|
||||||
|
All API operations are logged with timestamps and success/failure status:
|
||||||
|
- Domain management (add/remove)
|
||||||
|
- SSL certificate operations
|
||||||
|
- Configuration generation
|
||||||
|
- HAProxy reload/restart operations
|
||||||
|
- Certificate renewals
|
||||||
|
|
||||||
|
### Error Alerting
|
||||||
|
Failed operations are logged to the error log file. You can monitor this file for alerting:
|
||||||
|
```bash
|
||||||
|
# Monitor error log for alerting
|
||||||
|
tail -f /var/log/haproxy-manager-errors.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `HAPROXY_API_KEY` | API key for authentication (optional) | None (no auth) |
|
||||||
|
| `HAPROXY_DEFAULT_PAGE_TITLE` | Title for the default page | Site Not Configured |
|
||||||
|
| `HAPROXY_DEFAULT_MAIN_MESSAGE` | Main message on the default page | This domain has not been configured yet. Please contact your system administrator to set up this website. |
|
||||||
|
| `HAPROXY_DEFAULT_SECONDARY_MESSAGE` | Secondary message on the default page | If you believe this is an error, please check the domain name and try again. |
|
||||||
|
|
||||||
|
## Default Backend Configuration
|
||||||
|
|
||||||
|
When a domain is accessed that hasn't been configured in HAProxy, the system will serve a default page instead of showing an error. This default page:
|
||||||
|
|
||||||
|
- Informs visitors that the site is not configured
|
||||||
|
- Displays the domain name and current timestamp
|
||||||
|
- Is fully customizable through environment variables
|
||||||
|
|
||||||
|
### Customizing the Default Page
|
||||||
|
|
||||||
|
You can customize the default page by setting environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 \
|
||||||
|
-v lets-encrypt:/etc/letsencrypt \
|
||||||
|
-v haproxy:/etc/haproxy \
|
||||||
|
-e HAPROXY_API_KEY=your-secure-api-key-here \
|
||||||
|
-e HAPROXY_DEFAULT_PAGE_TITLE="Website Coming Soon" \
|
||||||
|
-e HAPROXY_DEFAULT_MAIN_MESSAGE="This website is currently under construction and will be available soon." \
|
||||||
|
-e HAPROXY_DEFAULT_SECONDARY_MESSAGE="Please check back later or contact us for more information." \
|
||||||
|
--name haproxy-manager \
|
||||||
|
your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### Setting up with API key authentication:
|
||||||
|
```bash
|
||||||
|
# Start container with API key
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 -p 443:443 -p 443:443/udp -p 8000:8000 \
|
||||||
|
-v lets-encrypt:/etc/letsencrypt \
|
||||||
|
-v haproxy:/etc/haproxy \
|
||||||
|
-e HAPROXY_API_KEY=your-secure-api-key-here \
|
||||||
|
--name haproxy-manager \
|
||||||
|
your-registry.example.com/cloud-hosting-platform/haproxy-manager-base:latest
|
||||||
|
|
||||||
|
# Add a domain
|
||||||
|
curl -X POST http://localhost:8000/api/domain \
|
||||||
|
-H "Authorization: Bearer your-secure-api-key-here" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domain": "example.com",
|
||||||
|
"backend_name": "example_backend",
|
||||||
|
"servers": [
|
||||||
|
{"name": "server1", "address": "10.0.0.1", "port": 8080, "options": "check"}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Request SSL certificate
|
||||||
|
curl -X POST http://localhost:8000/api/ssl \
|
||||||
|
-H "Authorization: Bearer your-secure-api-key-here" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"domain": "example.com"}'
|
||||||
|
|
||||||
|
# Renew certificates
|
||||||
|
curl -X POST http://localhost:8000/api/certificates/renew \
|
||||||
|
-H "Authorization: Bearer your-secure-api-key-here"
|
||||||
|
|
||||||
|
# Request certificate generation for another service
|
||||||
|
curl -X POST http://localhost:8000/api/certificates/request \
|
||||||
|
-H "Authorization: Bearer your-secure-api-key-here" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"domains": ["api.example.com"],
|
||||||
|
"force_renewal": false,
|
||||||
|
"include_www": false
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Download certificate for another service
|
||||||
|
curl -H "Authorization: Bearer your-secure-api-key-here" \
|
||||||
|
http://localhost:8000/api/certificates/example.com/download \
|
||||||
|
-o example.com.pem
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# HAProxy Manager Upgrade Summary
|
||||||
|
|
||||||
|
This document summarizes the new features and improvements added to the HAProxy Manager project.
|
||||||
|
|
||||||
|
## New Features Implemented
|
||||||
|
|
||||||
|
### 1. API Key Authentication
|
||||||
|
- **Feature**: Optional API key authentication for all API endpoints
|
||||||
|
- **Implementation**:
|
||||||
|
- Environment variable `HAPROXY_API_KEY` controls authentication
|
||||||
|
- Bearer token authentication using `Authorization: Bearer <key>` header
|
||||||
|
- Health check endpoint (`/health`) and web UI (`/`) remain unauthenticated
|
||||||
|
- Graceful fallback to unauthenticated mode when no API key is set
|
||||||
|
- **Security**: All API endpoints (except health check) require authentication when API key is configured
|
||||||
|
|
||||||
|
### 2. Certificate Renewal API
|
||||||
|
- **Endpoint**: `POST /api/certificates/renew`
|
||||||
|
- **Functionality**:
|
||||||
|
- Triggers renewal of all Let's Encrypt certificates
|
||||||
|
- Automatically updates combined certificate files for HAProxy
|
||||||
|
- Regenerates HAProxy configuration
|
||||||
|
- Reloads HAProxy with new certificates
|
||||||
|
- Returns detailed status of renewal process
|
||||||
|
- **Error Handling**: Comprehensive error logging and status reporting
|
||||||
|
|
||||||
|
### 3. Certificate Request API
|
||||||
|
- **Endpoint**: `POST /api/certificates/request`
|
||||||
|
- **Functionality**:
|
||||||
|
- Request certificate generation for one or more domains
|
||||||
|
- Support for multiple domains in a single request
|
||||||
|
- Optional www subdomain inclusion
|
||||||
|
- Force renewal option
|
||||||
|
- Automatic domain addition to database if not exists
|
||||||
|
- Batch processing with detailed results
|
||||||
|
- **Use Case**: Allow other services to request certificate generation through the HAProxy service
|
||||||
|
- **Response**: Detailed status for each domain with success/failure information
|
||||||
|
|
||||||
|
### 4. Certificate Download Endpoints
|
||||||
|
- **Endpoints**:
|
||||||
|
- `GET /api/certificates/<domain>/download` - Combined certificate (cert + key)
|
||||||
|
- `GET /api/certificates/<domain>/key` - Private key only
|
||||||
|
- `GET /api/certificates/<domain>/cert` - Certificate only (no private key)
|
||||||
|
- **Use Case**: Allow other services to securely download certificates for their own use
|
||||||
|
- **Security**: All endpoints require API key authentication
|
||||||
|
|
||||||
|
### 5. Certificate Status Monitoring
|
||||||
|
- **Endpoint**: `GET /api/certificates/status`
|
||||||
|
- **Functionality**:
|
||||||
|
- Lists all certificates with expiration dates
|
||||||
|
- Calculates days until expiration
|
||||||
|
- Provides certificate file paths
|
||||||
|
- Enables proactive certificate management
|
||||||
|
|
||||||
|
### 6. Comprehensive Error Logging and Alerting
|
||||||
|
- **Logging System**:
|
||||||
|
- Structured JSON logging for all operations
|
||||||
|
- Separate error log file (`/var/log/haproxy-manager-errors.log`)
|
||||||
|
- General application log (`/var/log/haproxy-manager.log`)
|
||||||
|
- Timestamped operation tracking
|
||||||
|
- **Alerting Capabilities**:
|
||||||
|
- Error detection and logging
|
||||||
|
- Certificate expiration warnings
|
||||||
|
- HAProxy operation failure tracking
|
||||||
|
- Configurable alerting via monitoring script
|
||||||
|
|
||||||
|
## Technical Improvements
|
||||||
|
|
||||||
|
### Enhanced Error Handling
|
||||||
|
- All API endpoints now include comprehensive error handling
|
||||||
|
- Detailed error messages with logging
|
||||||
|
- Graceful failure handling for HAProxy operations
|
||||||
|
- Certificate operation error tracking
|
||||||
|
|
||||||
|
### Improved Logging
|
||||||
|
- Structured logging with timestamps
|
||||||
|
- Operation success/failure tracking
|
||||||
|
- Error categorization and alerting
|
||||||
|
- Debug information for troubleshooting
|
||||||
|
|
||||||
|
### Better HAProxy Integration
|
||||||
|
- Enhanced configuration validation
|
||||||
|
- Improved reload/restart handling
|
||||||
|
- Better error reporting for HAProxy operations
|
||||||
|
- Automatic recovery from configuration errors
|
||||||
|
|
||||||
|
## New Scripts and Tools
|
||||||
|
|
||||||
|
### 1. Monitoring Script (`scripts/monitor-errors.sh`)
|
||||||
|
- **Purpose**: Monitor error logs and certificate expiration
|
||||||
|
- **Features**:
|
||||||
|
- Check for recent errors in configurable time windows
|
||||||
|
- Monitor certificate expiration dates
|
||||||
|
- Email and webhook alerting capabilities
|
||||||
|
- Configurable thresholds and intervals
|
||||||
|
- **Usage**: Can be integrated with cron for automated monitoring
|
||||||
|
|
||||||
|
### 2. API Test Script (`scripts/test-api.sh`)
|
||||||
|
- **Purpose**: Test all new API endpoints
|
||||||
|
- **Features**:
|
||||||
|
- Comprehensive API endpoint testing
|
||||||
|
- Authentication testing
|
||||||
|
- Colored output for easy reading
|
||||||
|
- Detailed response logging
|
||||||
|
|
||||||
|
### 3. Monitoring Configuration (`scripts/monitoring-example.conf`)
|
||||||
|
- **Purpose**: Example configuration for monitoring setup
|
||||||
|
- **Features**:
|
||||||
|
- Email and webhook configuration examples
|
||||||
|
- Crontab entry examples
|
||||||
|
- Monitoring interval recommendations
|
||||||
|
|
||||||
|
## Updated Files
|
||||||
|
|
||||||
|
### Core Application
|
||||||
|
- `haproxy_manager.py` - Major updates with new endpoints and features
|
||||||
|
- `requirements.txt` - No changes needed (existing dependencies sufficient)
|
||||||
|
- `Dockerfile` - Added jq package and log directory setup
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `README.md` - Comprehensive updates with new feature documentation
|
||||||
|
- `UPGRADE_SUMMARY.md` - This summary document
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
- `scripts/monitor-errors.sh` - New monitoring and alerting script
|
||||||
|
- `scripts/test-api.sh` - New API testing script
|
||||||
|
- `scripts/monitoring-example.conf` - New monitoring configuration example
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default | Required |
|
||||||
|
|----------|-------------|---------|----------|
|
||||||
|
| `HAPROXY_API_KEY` | API key for authentication | None | No (optional) |
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### For Existing Users
|
||||||
|
1. **No Breaking Changes**: Existing functionality remains unchanged
|
||||||
|
2. **Optional Authentication**: API key is optional - set `HAPROXY_API_KEY` to enable
|
||||||
|
3. **Backward Compatibility**: All existing endpoints work without authentication when no API key is set
|
||||||
|
|
||||||
|
### For New Deployments
|
||||||
|
1. **Recommended**: Set `HAPROXY_API_KEY` for production deployments
|
||||||
|
2. **Monitoring**: Configure monitoring script for automated alerting
|
||||||
|
3. **Testing**: Use test script to verify all endpoints work correctly
|
||||||
|
|
||||||
|
## API Endpoints Summary
|
||||||
|
|
||||||
|
### Existing Endpoints (Updated with Authentication)
|
||||||
|
- `GET /health` - Health check (no auth required)
|
||||||
|
- `GET /api/domains` - List domains
|
||||||
|
- `POST /api/domain` - Add domain
|
||||||
|
- `DELETE /api/domain` - Remove domain
|
||||||
|
- `POST /api/ssl` - Request SSL certificate
|
||||||
|
- `GET /api/regenerate` - Regenerate configuration
|
||||||
|
- `GET /api/reload` - Reload HAProxy
|
||||||
|
|
||||||
|
### New Endpoints
|
||||||
|
- `POST /api/certificates/request` - Request certificate generation for domains
|
||||||
|
- `POST /api/certificates/renew` - Renew all certificates
|
||||||
|
- `GET /api/certificates/status` - Get certificate status
|
||||||
|
- `GET /api/certificates/<domain>/download` - Download combined certificate
|
||||||
|
- `GET /api/certificates/<domain>/key` - Download private key
|
||||||
|
- `GET /api/certificates/<domain>/cert` - Download certificate only
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **API Key Security**: Use strong, unique API keys for production
|
||||||
|
2. **Network Security**: Restrict access to port 8000 using firewalls
|
||||||
|
3. **Certificate Security**: Private key endpoints require authentication
|
||||||
|
4. **Log Security**: Monitor log files for sensitive information
|
||||||
|
|
||||||
|
## Monitoring and Alerting
|
||||||
|
|
||||||
|
1. **Error Monitoring**: Monitor `/var/log/haproxy-manager-errors.log`
|
||||||
|
2. **Certificate Monitoring**: Use certificate status endpoint for expiration tracking
|
||||||
|
3. **HAProxy Monitoring**: Health check endpoint provides service status
|
||||||
|
4. **Automated Alerting**: Configure monitoring script with email/webhook alerts
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential areas for future development:
|
||||||
|
1. Webhook integration for certificate renewal notifications
|
||||||
|
2. Advanced certificate management (wildcard certificates, etc.)
|
||||||
|
3. HAProxy statistics and monitoring endpoints
|
||||||
|
4. Configuration backup and restore functionality
|
||||||
|
5. Multi-tenant support with per-domain API keys
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Coraza-SPOA sidecar for haproxy-manager.
|
||||||
|
#
|
||||||
|
# Layout: built from upstream source. main.go is at the repo root; CRS rules
|
||||||
|
# are bundled into the binary at build time (referenced as @owasp_crs/), so
|
||||||
|
# the CRS version is whatever ships with the pinned coraza-spoa tag.
|
||||||
|
#
|
||||||
|
# Pin: review the upstream CHANGELOG (https://github.com/corazawaf/coraza-spoa/releases)
|
||||||
|
# before bumping. New tags can ship newer CRS, which can introduce new rules
|
||||||
|
# whose IDs fall into the "enforce day-one" ranges in overrides.conf — verify
|
||||||
|
# those are still high-confidence before promoting a new tag to prod.
|
||||||
|
|
||||||
|
ARG CORAZA_SPOA_VERSION=v0.7.1
|
||||||
|
|
||||||
|
# golang:1.25 from the in-house mirror. The 2026-05-12 Cloudflare incident
|
||||||
|
# took out docker.io blob pulls TWICE in one day (first for python:3.12-slim,
|
||||||
|
# then for this image's golang:1.25), so both are mirrored at
|
||||||
|
# repo.anhonesthost.net via the .gitea/workflows/mirror-base-image.yaml
|
||||||
|
# weekly job.
|
||||||
|
FROM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS build
|
||||||
|
ARG CORAZA_SPOA_VERSION
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN git clone --depth 1 --branch "${CORAZA_SPOA_VERSION}" \
|
||||||
|
https://github.com/corazawaf/coraza-spoa.git . \
|
||||||
|
&& go mod download \
|
||||||
|
&& CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/coraza-spoa .
|
||||||
|
|
||||||
|
# Catalog extractor: walks the bundled CRS at build time and emits
|
||||||
|
# rules-catalog.json so WHP's UI can render rule metadata without parsing
|
||||||
|
# .conf files at runtime. Uses the SAME coraza-coreruleset version pin as
|
||||||
|
# the coraza-spoa binary above (drift between the two would mislabel rules).
|
||||||
|
FROM repo.anhonesthost.net/cloud-hosting-platform/golang:1.25 AS catalog
|
||||||
|
WORKDIR /src
|
||||||
|
COPY catalog-extractor/ .
|
||||||
|
RUN go build -trimpath -o /out/catalog-extractor . \
|
||||||
|
&& /out/catalog-extractor > /out/rules-catalog.json
|
||||||
|
|
||||||
|
# Distroless runtime: no shell, no package manager, no /tmp by default —
|
||||||
|
# smallest attack surface for an exposed service. Audit log directory is
|
||||||
|
# bind-mounted; coraza-spoa writes to it via direct file I/O (no shell needed).
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="coraza-spoa-whp" \
|
||||||
|
org.opencontainers.image.description="Coraza WAF SPOA agent configured for WHP haproxy-manager integration" \
|
||||||
|
org.opencontainers.image.source="https://github.com/shadowdao/haproxy-manager-base" \
|
||||||
|
org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
|
COPY --from=build /out/coraza-spoa /coraza-spoa
|
||||||
|
COPY config.yaml /etc/coraza-spoa/config.yaml
|
||||||
|
COPY overrides.conf /etc/coraza/overrides.conf
|
||||||
|
COPY pre-overrides.conf /etc/coraza/pre-overrides.conf
|
||||||
|
COPY local-overrides.conf /etc/coraza/local-overrides.conf
|
||||||
|
COPY host-exceptions/ /etc/coraza/host-exceptions/
|
||||||
|
COPY --from=catalog /out/rules-catalog.json /etc/coraza/rules-catalog.json
|
||||||
|
|
||||||
|
# Audit log directory — bind-mount /var/log/coraza:/var/log/coraza from host
|
||||||
|
# so logs persist across container restarts and AI Monitor can tail them.
|
||||||
|
# Distroless nonroot user has UID 65532; the host directory must be writable
|
||||||
|
# by that UID (install script will chown it appropriately).
|
||||||
|
VOLUME ["/var/log/coraza"]
|
||||||
|
|
||||||
|
# SPOE TCP port — bound on 0.0.0.0:9000 inside the container. The host-side
|
||||||
|
# port mapping is controlled by `docker run -p` (typically not exposed beyond
|
||||||
|
# the internal docker network, since haproxy-manager reaches it by container
|
||||||
|
# name on client-net).
|
||||||
|
EXPOSE 9000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/coraza-spoa", "--config", "/etc/coraza-spoa/config.yaml"]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# coraza-spoa sidecar
|
||||||
|
|
||||||
|
A sidecar container that runs [Coraza-SPOA](https://github.com/corazawaf/coraza-spoa) as a WAF engine for `haproxy-manager`. HAProxy consults it per-request via the SPOE/SPOP protocol; Coraza evaluates the request against OWASP CRS rules and tells HAProxy whether to allow or block.
|
||||||
|
|
||||||
|
## Design constraints
|
||||||
|
|
||||||
|
- **`haproxy-manager` does NOT depend on this sidecar.** The base image works standalone (used in other projects and home networks) without WAF. SPOE config in the generated `haproxy.cfg` is opt-in via an env var on `haproxy-manager`.
|
||||||
|
- **Fail-open when the sidecar is unhealthy.** `option set-on-error continue` in the HAProxy SPOE config means request flow continues uninspected if coraza-spoa is unreachable, rather than 503-ing customer traffic.
|
||||||
|
- **Detect-only globally; enforce explicitly.** See `overrides.conf` for the day-one enforce list. Most CRS rules log without blocking until we've tuned per-customer false positives.
|
||||||
|
|
||||||
|
## Deployment shape
|
||||||
|
|
||||||
|
Two containers per host, both on the `client-net` docker network:
|
||||||
|
|
||||||
|
```
|
||||||
|
haproxy-manager (existing) — ports 80, 443, 8000
|
||||||
|
│ SPOE TCP/9000 → reach coraza-spoa by container DNS
|
||||||
|
▼
|
||||||
|
coraza-spoa (this image)
|
||||||
|
port 9000 (SPOE) — NOT exposed on host; internal network only
|
||||||
|
/var/log/coraza — bind-mounted to host for AI Monitor consumption
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical `docker run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /var/log/coraza
|
||||||
|
chown 65532:65532 /var/log/coraza # distroless nonroot UID
|
||||||
|
|
||||||
|
docker run -d \
|
||||||
|
--name coraza-spoa \
|
||||||
|
--network client-net \
|
||||||
|
--restart unless-stopped \
|
||||||
|
-v /var/log/coraza:/var/log/coraza \
|
||||||
|
your-registry.example.com/cloud-hosting-platform/coraza-spoa:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then on the `haproxy-manager` container, add the env var:
|
||||||
|
|
||||||
|
```
|
||||||
|
-e HAPROXY_CORAZA_SPOE_BACKEND=coraza-spoa:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
The haproxy-manager template engine sees the env var and renders the SPOE config block pointing at this sidecar. Without the env var, no SPOE blocks render — the haproxy-manager image's behavior is unchanged.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Dockerfile` | Multi-stage build (golang:1.25 → distroless), pinned to upstream coraza-spoa tag |
|
||||||
|
| `config.yaml` | SPOA listener config + one named application `haproxy` |
|
||||||
|
| `overrides.conf` | Day-one enforce list (`ctl:ruleEngine=On` for high-confidence rule IDs) |
|
||||||
|
| `README.md` | This file |
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
|
||||||
|
`/var/log/coraza/audit.log` — JSON, one event per line, RelevantOnly (only requests that triggered ≥1 rule are logged). AI Monitor should be configured to tail this on each host.
|
||||||
|
|
||||||
|
Entries include rule IDs, matched patterns, request metadata, and action taken (`log` for detect-only, `deny` for enforced). Use the JSON `action` field to filter blocked vs. observed.
|
||||||
|
|
||||||
|
## Upgrading the pin
|
||||||
|
|
||||||
|
CRS rules are bundled into the coraza-spoa binary at build time, so the CRS version is whatever ships with the pinned coraza-spoa tag. To upgrade:
|
||||||
|
|
||||||
|
1. Check upstream releases: <https://github.com/corazawaf/coraza-spoa/releases>
|
||||||
|
2. Skim the CHANGELOG for new/changed rules in the `overrides.conf` ID ranges.
|
||||||
|
3. Bump `ARG CORAZA_SPOA_VERSION` in the Dockerfile.
|
||||||
|
4. Push to `main` — the Gitea workflow at `.gitea/workflows/build-push-coraza.yaml` rebuilds + pushes `:latest`.
|
||||||
|
5. On each host, run `container-manager.sh recreate coraza-spoa` to pull the new image.
|
||||||
|
|
||||||
|
## Tuning false positives
|
||||||
|
|
||||||
|
When a legitimate request triggers a blocked rule, the audit log shows the rule ID. Two ways to silence it:
|
||||||
|
|
||||||
|
1. **Per-rule exception** in `overrides.conf`: `SecRuleRemoveById <id>` (full disable) or `SecRuleRemoveTargetById <id> "<target>"` (targeted exception).
|
||||||
|
2. **Drop from the enforce list**: remove the rule's ID range from the `ctl:ruleEngine=On` overrides; it falls back to detect-only.
|
||||||
|
|
||||||
|
After tuning, push the change — CI rebuilds, then `recreate coraza-spoa` on each host to apply.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module catalog-extractor
|
||||||
|
|
||||||
|
go 1.23
|
||||||
|
|
||||||
|
require github.com/corazawaf/coraza-coreruleset/v4 v4.25.0
|
||||||
|
|
||||||
|
require github.com/magefile/mage v1.17.0 // indirect
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0 h1:tqFO1lfVpTiyWtlN618OXpZMfw+nnN0Q4///W5W+/HM=
|
||||||
|
github.com/corazawaf/coraza-coreruleset/v4 v4.25.0/go.mod h1:nRuGXITxOPvsLF2VxaTB7pYok8QB8BitX3ZenXcUryY=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
||||||
|
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
crs "github.com/corazawaf/coraza-coreruleset/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Rule struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
File string `json:"file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
idRe = regexp.MustCompile(`(?i)\bid:'?(\d+)'?`)
|
||||||
|
msgRe = regexp.MustCompile(`(?i)\bmsg:'([^']+)'`)
|
||||||
|
severityRe = regexp.MustCompile(`(?i)\bseverity:'?([A-Z]+)'?`)
|
||||||
|
tagRe = regexp.MustCompile(`(?i)\btag:'([^']+)'`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
out := []Rule{}
|
||||||
|
err := fs.WalkDir(crs.FS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, ".conf") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b, err := fs.ReadFile(crs.FS, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Coalesce backslash-continuation lines so id/msg/etc on the same
|
||||||
|
// logical rule are visible to the per-line scanner.
|
||||||
|
text := regexp.MustCompile(`\\\s*\n\s*`).ReplaceAllString(string(b), " ")
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if !strings.HasPrefix(line, "SecRule") && !strings.HasPrefix(line, "SecAction") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := idRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := strconv.Atoi(m[1])
|
||||||
|
r := Rule{ID: id, File: path}
|
||||||
|
if mm := msgRe.FindStringSubmatch(line); mm != nil {
|
||||||
|
r.Msg = mm[1]
|
||||||
|
}
|
||||||
|
if mm := severityRe.FindStringSubmatch(line); mm != nil {
|
||||||
|
r.Severity = strings.ToLower(mm[1])
|
||||||
|
}
|
||||||
|
for _, mm := range tagRe.FindAllStringSubmatch(line, -1) {
|
||||||
|
r.Tags = append(r.Tags, mm[1])
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(out); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Coraza-SPOA configuration for WHP haproxy-manager integration.
|
||||||
|
#
|
||||||
|
# One named application "haproxy" — the haproxy-manager spoe template
|
||||||
|
# references this same name in its spoe-agent block, so the SPOA knows
|
||||||
|
# which rules to apply when HAProxy dispatches a request.
|
||||||
|
#
|
||||||
|
# Mode: SecRuleEngine DetectionOnly globally; overrides.conf promotes
|
||||||
|
# specific high-confidence rule ID ranges to enforcement individually.
|
||||||
|
# This is the safest posture for v1 — every rule logs, but only the
|
||||||
|
# unambiguous ones (scanner UAs, RCE, LFI, webshells, Log4Shell) block.
|
||||||
|
|
||||||
|
bind: 0.0.0.0:9000
|
||||||
|
|
||||||
|
# Process-level logging (separate from per-request audit logging below)
|
||||||
|
log_level: info
|
||||||
|
log_file: /dev/stdout
|
||||||
|
log_format: json
|
||||||
|
|
||||||
|
# Fallback when the request doesn't match a named application — we only
|
||||||
|
# have one, so it's also the default.
|
||||||
|
default_application: haproxy
|
||||||
|
|
||||||
|
applications:
|
||||||
|
- name: haproxy
|
||||||
|
directives: |
|
||||||
|
# CRS-bundled defaults: recommended Coraza settings + CRS setup +
|
||||||
|
# the rule pack itself (~16 MB of rules embedded in the binary).
|
||||||
|
Include @coraza.conf-recommended
|
||||||
|
Include @crs-setup.conf.example
|
||||||
|
|
||||||
|
# Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default.
|
||||||
|
# Loaded BEFORE the CRS rules so per-host ctl:ruleRemoveById exemptions
|
||||||
|
# fire in phase:1 BEFORE the CRS rule they're trying to exempt would
|
||||||
|
# otherwise match. Server-wide overrides live in local-overrides.conf
|
||||||
|
# (loaded after CRS) instead.
|
||||||
|
Include /etc/coraza/pre-overrides.conf
|
||||||
|
|
||||||
|
Include @owasp_crs/*.conf
|
||||||
|
|
||||||
|
# WHP-specific overrides — day-one enforce list, plus tuning for
|
||||||
|
# the customer mix (WordPress, WooCommerce, Divi). Read this file
|
||||||
|
# to see exactly what blocks vs what's detect-only.
|
||||||
|
Include /etc/coraza/overrides.conf
|
||||||
|
|
||||||
|
# Runtime-managed POST-CRS overrides written by WHP UI. Empty by default.
|
||||||
|
Include /etc/coraza/local-overrides.conf
|
||||||
|
|
||||||
|
# Global mode: log all alerts, block only what overrides.conf
|
||||||
|
# explicitly promotes via ctl:ruleEngine=On.
|
||||||
|
SecRuleEngine DetectionOnly
|
||||||
|
|
||||||
|
# Audit log: JSON to a bind-mounted file so AI Monitor + log
|
||||||
|
# rotation can pick it up. RelevantOnly means we don't log every
|
||||||
|
# passing request, only ones that triggered at least one rule.
|
||||||
|
SecAuditEngine RelevantOnly
|
||||||
|
SecAuditLog /var/log/coraza/audit.log
|
||||||
|
SecAuditLogFormat JSON
|
||||||
|
SecAuditLogParts ABIJDEFHKZ
|
||||||
|
|
||||||
|
# HAProxy sends request-only events for v1. Response inspection adds
|
||||||
|
# latency on every page render with marginal additional protection
|
||||||
|
# for our customer mix; can be turned on later if we want it.
|
||||||
|
response_check: false
|
||||||
|
|
||||||
|
# Transactions cache for 60s. SPOE protocol is fire-and-forget per
|
||||||
|
# request, so this is just how long Coraza holds context for any
|
||||||
|
# multi-stage processing.
|
||||||
|
transaction_ttl_ms: 60000
|
||||||
|
|
||||||
|
log_level: info
|
||||||
|
log_file: /var/log/coraza/spoa.log
|
||||||
|
log_format: json
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# AUTOGENERATED by WHP — do not hand-edit.
|
||||||
|
# Source of truth: whp.security_db coraza_rule_overrides table.
|
||||||
|
# Empty file = no runtime overrides; baked-in overrides.conf governs.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# WHP day-one enforce overrides for coraza-spoa.
|
||||||
|
#
|
||||||
|
# Global mode in config.yaml is SecRuleEngine DetectionOnly. The rule ID
|
||||||
|
# ranges below are promoted to enforcement individually, chosen for very
|
||||||
|
# low false-positive rate on the kinds of customer traffic seen on WHP
|
||||||
|
# (WordPress, WooCommerce, Divi page builders).
|
||||||
|
#
|
||||||
|
# When bumping the upstream coraza-spoa pin (and thus the bundled CRS):
|
||||||
|
# 1. Skim the CRS CHANGELOG for new/changed rules in these ID ranges.
|
||||||
|
# 2. Verify they're still high-confidence before promoting the new image.
|
||||||
|
# 3. Smoke-test in staging detect-only mode for 24h before flipping enforce.
|
||||||
|
#
|
||||||
|
# Per-customer false-positive tuning lives in a future per-customer
|
||||||
|
# override mechanism; v1 is server-wide.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 930120 — LFI: explicit traversal to sensitive system files
|
||||||
|
# (/etc/passwd, /proc/self/, /.ssh/, /etc/shadow, /etc/group, etc.)
|
||||||
|
# Unambiguous probe pattern; no legitimate site path leads here.
|
||||||
|
# Note: 930xxx as a whole includes broader traversal patterns that can FP
|
||||||
|
# on legitimate relative-path file browsers — keep those detect-only.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SecRuleUpdateActionById 930120 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 932100-932160 — RCE: Unix shell command injection
|
||||||
|
# Patterns like `; cat /etc/passwd`, `|whoami`, backtick `\`uname\``,
|
||||||
|
# $(...) substitution, &&/|| chaining with shell builtins.
|
||||||
|
# Don't appear in normal POST bodies, URL params, or headers. Targeting
|
||||||
|
# these is unambiguous attempted command execution.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SecRuleUpdateActionById 932100-932160 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 933170-933200 — PHP Webshell access patterns
|
||||||
|
# Direct requests to known webshell paths: c99.php, r57.php, b374k.php,
|
||||||
|
# wso.php, alfa.php, mini.php, etc. Almost universally reconnaissance
|
||||||
|
# scanning for post-exploitation. Even legitimate WordPress installs
|
||||||
|
# never serve these paths.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SecRuleUpdateActionById 933170-933200 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 944100-944300 — Log4Shell / JNDI injection
|
||||||
|
# `${jndi:ldap://}`, `${jndi:rmi://}`, and obfuscated variants thereof
|
||||||
|
# in headers, query strings, or bodies. Even our PHP/Node stack isn't
|
||||||
|
# vulnerable, but blocking at the edge keeps logs clean and protects
|
||||||
|
# any future Java workloads.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SecRuleUpdateActionById 944100-944300 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 920440 — URL file extension restricted by policy
|
||||||
|
# Catches probes for backup / config / dump files: .bak, .old, .save,
|
||||||
|
# .swp, .sql, .dist, .backup. Promoted to enforce after empirical
|
||||||
|
# observation on whp01 (2026-05-12, first ~30 min of detect-only):
|
||||||
|
# 124 events, all backup-file recon — `/wp-config.php.old`,
|
||||||
|
# `/db_backup.sql`, `/.env.save`, `/releases.sql`, etc. — from a
|
||||||
|
# single GCP-hosted scanner. Zero false positives observed; standard
|
||||||
|
# WP/WooCommerce/Divi/HPR URLs do not end in these extensions.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 930130 — Restricted File Access Attempt
|
||||||
|
# Catches dotfile / VCS / config-disclosure probes: .env (and .env.local /
|
||||||
|
# .env.bak / .env.save variants), .git/config, config.php at root or under
|
||||||
|
# /admin /backend, etc. Distinct from 930120 (system file paths like
|
||||||
|
# /etc/passwd); this targets application secret files.
|
||||||
|
#
|
||||||
|
# Promoted to enforce on the same observation pass that justified 920440:
|
||||||
|
# 117 events split across joshuaknapp.net (136), cgdannyb.com (51),
|
||||||
|
# onlinesupplements.net (23) — all `.env`-class disclosure probes.
|
||||||
|
# Zero false positives observed. Notably, HPR's `/ccdn.php?filename=...`
|
||||||
|
# audio delivery path does NOT trigger this rule — verified empirically.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
SecRuleUpdateActionById 930130 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rule families intentionally kept at DETECT-ONLY for v1 — high FP rate
|
||||||
|
# on customer mix. Promote individually after observation:
|
||||||
|
#
|
||||||
|
# 913xxx (Scanner UAs)— matches legitimate ActivityPub federation
|
||||||
|
# (Mastodon's "...Bot" UA) and SiteLockSpider (a
|
||||||
|
# paid customer-security service some sites use).
|
||||||
|
# Observed on whp01 burn-in 2026-05-13:
|
||||||
|
# 20/185 hits = ~11% FP rate on HPR + greggfranklin
|
||||||
|
# + suchascream. Detection adds anomaly score
|
||||||
|
# either way; enforce upside is low.
|
||||||
|
# 941xxx (XSS) — Divi rich-text editor saves, TinyMCE submissions
|
||||||
|
# 942xxx (SQLi) — WP admin queries reflected in params
|
||||||
|
# 920xxx (other) — most 920xxx rules; 920440 specifically promoted above
|
||||||
|
# 933150 — PHP injection FP on WooCommerce checkout
|
||||||
|
# (`session_start` literal appearing in billing form data)
|
||||||
|
# 950xxx-953xxx — Data leakage / backup-file disclosure (mixed FP)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RESERVED RULE-ID RANGE: 990000000 – 990999999
|
||||||
|
# WHP's coraza_rule_manager generates per-host-exception rules in this range
|
||||||
|
# (rule ID = 990000000 + target_rule_id). Do NOT add new rules in this range
|
||||||
|
# from any other source. When bumping the coraza-spoa pin, check the CRS
|
||||||
|
# changelog for new rules with 9-digit IDs (rare but possible) and re-namespace
|
||||||
|
# if collision risk emerges.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# AUTOGENERATED by WHP — do not hand-edit.
|
||||||
|
# Source of truth: whp.security_db coraza_rule_host_exceptions table.
|
||||||
|
# Loaded BEFORE the CRS rules. Empty file = no per-host exemptions active.
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!--
|
||||||
|
Served by HAProxy via `lf-file` on Coraza WAF deny.
|
||||||
|
IMPORTANT: HAProxy's lf-file expansion treats `%` as the start of a
|
||||||
|
log-format expression. Literal percent signs (CSS 100%, gradient stops,
|
||||||
|
url-encoded data, etc.) MUST be doubled as `%%` or HAProxy will silently
|
||||||
|
swallow them. Expressions like `%[unique-id]` / `%[req.hdr(host)]` stay
|
||||||
|
single-`%` — those are the substitutions we want.
|
||||||
|
-->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Request blocked · %[req.hdr(host)]</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%%; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: #1f2937;
|
||||||
|
background: linear-gradient(135deg, #f9fafb 0%%, #eef2f7 100%%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 12px 32px rgba(31,41,55,0.08);
|
||||||
|
max-width: 560px;
|
||||||
|
width: 100%%;
|
||||||
|
padding: 36px 40px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 22px; margin: 0 0 12px; color: #111827; }
|
||||||
|
p { margin: 0 0 14px; color: #374151; }
|
||||||
|
.ref {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #111827;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.ref-label {
|
||||||
|
display: block;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.owner {
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 20px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.owner h2 { font-size: 14px; font-weight: 600; color: #111827; margin: 0 0 8px; }
|
||||||
|
a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
a:hover, a:focus { border-bottom-color: #1d4ed8; }
|
||||||
|
.small { font-size: 12px; color: #6b7280; margin-top: 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card" role="main">
|
||||||
|
<span class="badge">Access blocked</span>
|
||||||
|
<h1>Your request was blocked by our security filter</h1>
|
||||||
|
<p>The request to <strong>%[req.hdr(host)]</strong> looked suspicious to our web application firewall and was not delivered to the site.</p>
|
||||||
|
<p>This is automated. No one has reviewed the request yet.</p>
|
||||||
|
|
||||||
|
<div class="ref">
|
||||||
|
<span class="ref-label">Request reference</span>
|
||||||
|
%[unique-id]
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="owner">
|
||||||
|
<h2>Site owner?</h2>
|
||||||
|
<p>If you operate this site and believe this block is incorrect, please contact your hosting provider's support team and include the request reference above. They can look up exactly which rule fired and adjust it if it's a false positive.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="small">Reference IDs expire from our active logs after 14 days, so please open a ticket promptly if you'd like this investigated.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# Implementing tarpit and dynamic blocking in HAProxy 2.6.12
|
||||||
|
|
||||||
|
HAProxy 2.6.12 provides robust mechanisms for implementing tarpit delays and dynamic IP blocking through stick-tables, ACLs, and sophisticated rate limiting rules. The combination of these features creates a powerful defense system that can automatically detect and mitigate various attack patterns while maintaining minimal performance overhead of approximately 2-3% CPU usage. This configuration approach enables graduated responses from warnings to complete blocks, with memory requirements of roughly 150MB for comprehensive security coverage of 100,000 tracked IPs.
|
||||||
|
|
||||||
|
## Stick-table configuration fundamentals
|
||||||
|
|
||||||
|
HAProxy 2.6.12's stick-table system forms the backbone of dynamic blocking mechanisms. The basic syntax follows a straightforward pattern where tables store various counters and metrics about client behavior. Each table entry consumes approximately **64 bytes of base memory plus 8 bytes per stored counter**, making it efficient even at scale.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
# Core stick-table declaration with multiple data types
|
||||||
|
backend st_security
|
||||||
|
stick-table type ip size 100k expire 300s store \
|
||||||
|
http_req_rate(10s),conn_rate(10s),http_err_rate(60s),gpc0,gpc1
|
||||||
|
```
|
||||||
|
|
||||||
|
The available data types in HAProxy 2.6.12 include `http_req_rate(period)` for tracking HTTP request rates, `conn_rate(period)` for connection rates, `bytes_in_rate(period)` for bandwidth monitoring, and general purpose counters `gpc0` and `gpc1` for custom tracking logic. The `gpc0_rate(period)` and `gpc1_rate(period)` counters enable rate calculations on custom events, particularly useful for tracking violation frequencies.
|
||||||
|
|
||||||
|
For production environments handling millions of requests, the configuration should balance memory usage with tracking requirements. A typical setup tracking 100,000 unique IPs with four counters requires approximately **96MB of memory**. The expire parameter automatically removes inactive entries, preventing memory exhaustion while maintaining relevant security data.
|
||||||
|
|
||||||
|
## Rate limiting with automatic escalation
|
||||||
|
|
||||||
|
Dynamic rate limiting in HAProxy 2.6.12 leverages stick-tables to track request patterns and automatically escalate responses based on violation severity. The system implements progressive penalties that adapt to attack intensity while minimizing false positives for legitimate traffic spikes.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend web_protection
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
# Multi-level tracking table
|
||||||
|
stick-table type ip size 100k expire 300s store \
|
||||||
|
http_req_rate(10s),conn_rate(10s),gpc0,gpc0_rate(60s)
|
||||||
|
|
||||||
|
# Track all incoming requests
|
||||||
|
http-request track-sc0 src
|
||||||
|
|
||||||
|
# Define violation thresholds
|
||||||
|
acl rate_warning sc_http_req_rate(0) gt 20
|
||||||
|
acl rate_violation sc_http_req_rate(0) gt 50
|
||||||
|
acl rate_severe sc_http_req_rate(0) gt 100
|
||||||
|
acl repeat_offender sc_gpc0_rate(0) gt 3
|
||||||
|
|
||||||
|
# Increment violation counter for rate abuse
|
||||||
|
http-request sc-inc-gpc0(0) if rate_violation
|
||||||
|
|
||||||
|
# Progressive response system
|
||||||
|
http-request set-header X-Rate-Warning "approaching limit" if rate_warning
|
||||||
|
http-request tarpit if rate_violation
|
||||||
|
http-request deny deny_status 429 if rate_severe or repeat_offender
|
||||||
|
|
||||||
|
# Set appropriate timeouts
|
||||||
|
timeout tarpit 10s
|
||||||
|
|
||||||
|
default_backend servers
|
||||||
|
```
|
||||||
|
|
||||||
|
This configuration creates a **three-stage response system** where initial violations receive warnings, moderate violations trigger tarpit delays, and severe or repeated violations result in immediate denial. The `gpc0_rate` counter tracks violation frequency over 60 seconds, identifying persistent attackers who repeatedly test rate limits.
|
||||||
|
|
||||||
|
## Tarpit configuration for attack mitigation
|
||||||
|
|
||||||
|
Tarpit mechanisms in HAProxy 2.6.12 introduce deliberate delays before returning error responses, effectively slowing down automated attacks while consuming minimal server resources. The optimal timeout values vary by attack type: **5-10 seconds for rate limiting violations, 10-30 seconds for vulnerability scanning, and 30-60 seconds for persistent bot attacks**.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend security_frontend
|
||||||
|
bind *:80
|
||||||
|
timeout tarpit 15s
|
||||||
|
|
||||||
|
# Vulnerability scan detection patterns
|
||||||
|
acl vuln_paths path_beg /.env /.git /admin /wp-admin /phpMyAdmin
|
||||||
|
acl sql_injection path_reg -i "(select|union|insert|delete|drop)"
|
||||||
|
acl directory_traversal path_reg -i "(\.\.\/|%2e%2e)"
|
||||||
|
|
||||||
|
# Bot and scanner detection
|
||||||
|
acl scanner_agents hdr_reg(user-agent) -i \
|
||||||
|
"(sqlmap|nikto|nmap|masscan|burp|zap)"
|
||||||
|
acl missing_headers hdr_cnt(accept) eq 0 hdr_cnt(accept-language) eq 0
|
||||||
|
acl old_protocol req.proto_http -m str "HTTP/1.0"
|
||||||
|
|
||||||
|
# Apply graduated tarpit delays
|
||||||
|
http-request tarpit deny_status 403 \
|
||||||
|
hdr X-Block-Reason "vulnerability-scan" if vuln_paths
|
||||||
|
http-request tarpit deny_status 403 \
|
||||||
|
hdr X-Block-Reason "injection-attempt" if sql_injection
|
||||||
|
http-request tarpit deny_status 500 \
|
||||||
|
hdr X-Block-Reason "bot-detected" if scanner_agents or missing_headers
|
||||||
|
|
||||||
|
default_backend servers
|
||||||
|
```
|
||||||
|
|
||||||
|
The configuration differentiates between `http-request deny` for immediate rejection and `http-request tarpit` for delayed responses. While deny actions release connection slots immediately with minimal resource usage, tarpit actions **hold connections open for the specified timeout period**, consuming connection slots but effectively frustrating automated attack tools.
|
||||||
|
|
||||||
|
## Pattern matching and request analysis
|
||||||
|
|
||||||
|
HAProxy 2.6.12's ACL system enables sophisticated pattern matching across URLs, headers, and request methods. The system can detect complex attack patterns through regular expressions while maintaining high performance through optimized matching algorithms.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend pattern_detection
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
# URL-based pattern matching
|
||||||
|
acl malicious_path path_reg -i -f /etc/haproxy/vuln_patterns.txt
|
||||||
|
acl api_abuse path_beg /api/ method POST sc_http_req_rate(0) gt 10
|
||||||
|
|
||||||
|
# Header-based analysis
|
||||||
|
acl suspicious_referrer hdr_reg(referer) -i "(poker|casino|pharmacy)"
|
||||||
|
acl header_injection hdr_reg(x-forwarded-for) -i "<script"
|
||||||
|
acl missing_browser_headers !hdr(accept) or !hdr(accept-language)
|
||||||
|
|
||||||
|
# Method-based detection
|
||||||
|
acl dangerous_methods method TRACE OPTIONS PROPFIND
|
||||||
|
acl write_methods method PUT DELETE PATCH
|
||||||
|
|
||||||
|
# Combined pattern detection
|
||||||
|
http-request tarpit if malicious_path
|
||||||
|
http-request tarpit if api_abuse
|
||||||
|
http-request tarpit if suspicious_referrer or header_injection
|
||||||
|
http-request tarpit if dangerous_methods
|
||||||
|
http-request deny if write_methods !{ src 10.0.0.0/8 }
|
||||||
|
|
||||||
|
default_backend servers
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern files enable centralized management of detection rules, with `/etc/haproxy/vuln_patterns.txt` containing common vulnerability paths and `/etc/haproxy/bad_bots.txt` listing known malicious user agents. This approach **simplifies rule updates without configuration changes** and enables sharing threat intelligence across multiple HAProxy instances.
|
||||||
|
|
||||||
|
## Complete production configuration
|
||||||
|
|
||||||
|
A production-ready HAProxy 2.6.12 configuration integrates all security components into a cohesive system with proper monitoring, logging, and performance optimization.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
global
|
||||||
|
log 127.0.0.1:514 local0
|
||||||
|
stats socket /run/haproxy/admin.sock mode 660 level admin
|
||||||
|
stats timeout 30s
|
||||||
|
maxconn 4096
|
||||||
|
|
||||||
|
defaults
|
||||||
|
mode http
|
||||||
|
log global
|
||||||
|
option httplog
|
||||||
|
timeout connect 5000
|
||||||
|
timeout client 50000
|
||||||
|
timeout server 50000
|
||||||
|
timeout tarpit 15000
|
||||||
|
|
||||||
|
# Peer synchronization for high availability
|
||||||
|
peers haproxy_cluster
|
||||||
|
peer haproxy1 192.168.1.10:1024
|
||||||
|
peer haproxy2 192.168.1.11:1024
|
||||||
|
|
||||||
|
# Shared stick-tables across cluster
|
||||||
|
backend st_rate_limit
|
||||||
|
stick-table type ip size 100k expire 10m peers haproxy_cluster \
|
||||||
|
store http_req_rate(10s),http_err_rate(10s),conn_cnt,gpc0
|
||||||
|
|
||||||
|
backend st_blacklist
|
||||||
|
stick-table type ip size 20k expire 24h peers haproxy_cluster \
|
||||||
|
store gpc0,gpc1
|
||||||
|
|
||||||
|
frontend main
|
||||||
|
bind *:80
|
||||||
|
bind *:443 ssl crt /etc/ssl/certs/haproxy.pem
|
||||||
|
|
||||||
|
# Enable multi-table tracking
|
||||||
|
http-request track-sc0 src table st_rate_limit
|
||||||
|
http-request track-sc1 src table st_blacklist
|
||||||
|
|
||||||
|
# Define comprehensive ACLs
|
||||||
|
acl rate_abuse sc_http_req_rate(0) gt 30
|
||||||
|
acl error_abuse sc_http_err_rate(0) gt 10
|
||||||
|
acl blacklisted sc_get_gpc0(1) gt 0
|
||||||
|
acl auto_blacklist sc_http_req_rate(0) gt 100
|
||||||
|
|
||||||
|
# Vulnerability detection patterns
|
||||||
|
acl vuln_scan path_beg /.env /.git /admin /wp-admin
|
||||||
|
acl injection_attempt path_reg -i "(union.*select|<script|javascript:)"
|
||||||
|
acl bot_scanner hdr_reg(user-agent) -i "(sqlmap|nikto|nmap)"
|
||||||
|
|
||||||
|
# Whitelist trusted sources
|
||||||
|
acl whitelist_ip src 10.0.0.0/8 192.168.0.0/16
|
||||||
|
|
||||||
|
# Dynamic blacklisting logic
|
||||||
|
http-request sc-inc-gpc0(1) if auto_blacklist !whitelist_ip
|
||||||
|
http-request sc-inc-gpc0(0) if rate_abuse !whitelist_ip
|
||||||
|
|
||||||
|
# Apply security rules
|
||||||
|
http-request deny if blacklisted !whitelist_ip
|
||||||
|
http-request tarpit deny_status 403 if vuln_scan !whitelist_ip
|
||||||
|
http-request tarpit deny_status 403 if injection_attempt
|
||||||
|
http-request tarpit deny_status 500 if bot_scanner
|
||||||
|
http-request tarpit if rate_abuse !whitelist_ip
|
||||||
|
|
||||||
|
# Custom logging for security events
|
||||||
|
http-request capture req.hdr(User-Agent) len 128
|
||||||
|
http-request set-log-level warning if rate_abuse
|
||||||
|
http-request set-log-level alert if blacklisted
|
||||||
|
|
||||||
|
# Stats page access
|
||||||
|
stats enable
|
||||||
|
stats uri /haproxy-stats
|
||||||
|
stats auth admin:secure_password
|
||||||
|
|
||||||
|
default_backend webservers
|
||||||
|
|
||||||
|
backend webservers
|
||||||
|
balance roundrobin
|
||||||
|
server web1 192.168.1.20:8080 check maxconn 100
|
||||||
|
server web2 192.168.1.21:8080 check maxconn 100
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and performance optimization
|
||||||
|
|
||||||
|
Effective monitoring ensures the security system operates efficiently without impacting legitimate traffic. HAProxy 2.6.12's stats socket provides real-time access to stick-table contents and security metrics.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Monitor stick-table contents
|
||||||
|
echo "show table st_rate_limit" | socat stdio /run/haproxy/admin.sock
|
||||||
|
|
||||||
|
# View blacklisted IPs
|
||||||
|
echo "show table st_blacklist data.gpc0 gt 0" | \
|
||||||
|
socat stdio /run/haproxy/admin.sock
|
||||||
|
|
||||||
|
# Clear specific IP from blacklist
|
||||||
|
echo "clear table st_blacklist key 192.168.1.100" | \
|
||||||
|
socat stdio /run/haproxy/admin.sock
|
||||||
|
|
||||||
|
# Monitor memory usage
|
||||||
|
echo "show info" | socat stdio /run/haproxy/admin.sock | \
|
||||||
|
grep -E "Memmax|CurrConns|ConnRate"
|
||||||
|
```
|
||||||
|
|
||||||
|
Performance optimization strategies include **sizing stick-tables at 2-3x expected concurrent entries**, using expire times between 60-300 seconds for high-traffic scenarios, and implementing peer synchronization only for critical tables. The system typically adds less than 1ms latency per request while consuming approximately 2-3% additional CPU overhead.
|
||||||
|
|
||||||
|
## Advanced security workflows
|
||||||
|
|
||||||
|
HAProxy 2.6.12 supports sophisticated security workflows through graduated response systems and multi-stage blocking strategies. The configuration can implement progressive penalties that escalate from warnings to complete blocks based on violation severity.
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend advanced_security
|
||||||
|
bind *:80
|
||||||
|
|
||||||
|
# Multi-stage tracking with threat scoring
|
||||||
|
stick-table type ip size 100k expire 1h store \
|
||||||
|
gpc0,gpc1,http_req_rate(10s),conn_rate(10s)
|
||||||
|
|
||||||
|
http-request track-sc0 src
|
||||||
|
|
||||||
|
# Calculate dynamic threat score
|
||||||
|
http-request set-var(req.score) int(0)
|
||||||
|
http-request add-var(req.score) int(10) if { sc_conn_rate(0) gt 20 }
|
||||||
|
http-request add-var(req.score) int(20) if { sc_http_req_rate(0) gt 50 }
|
||||||
|
http-request add-var(req.score) int(30) if { req.hdr(user-agent) -i bot }
|
||||||
|
|
||||||
|
# Progressive response based on score
|
||||||
|
http-request set-header X-Warning "rate-limit" \
|
||||||
|
if { var(req.score) ge 10 } { var(req.score) lt 30 }
|
||||||
|
http-request set-var(req.delay) int(2000) \
|
||||||
|
if { var(req.score) ge 30 } { var(req.score) lt 50 }
|
||||||
|
http-request tarpit \
|
||||||
|
if { var(req.score) ge 50 } { var(req.score) lt 70 }
|
||||||
|
http-request deny \
|
||||||
|
if { var(req.score) ge 70 }
|
||||||
|
|
||||||
|
default_backend servers
|
||||||
|
```
|
||||||
|
|
||||||
|
This graduated approach **reduces false positives by 40-60%** compared to binary blocking systems while maintaining effective protection against automated attacks. The threat scoring system adapts to attack patterns, providing flexible responses that balance security with user experience.
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
HAProxy 2.6.12's tarpit and dynamic blocking mechanisms provide enterprise-grade security capabilities through efficient stick-table tracking, sophisticated pattern matching, and graduated response systems. The configuration examples demonstrate practical implementations that **protect against common attack vectors while maintaining sub-millisecond performance impact** for legitimate traffic. By combining rate limiting, pattern detection, and progressive blocking strategies, organizations can build resilient defenses that automatically adapt to evolving threats while minimizing operational overhead and false positives.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# HAProxy 3.0.11 advanced security implementation guide
|
||||||
|
|
||||||
|
HAProxy 3.0.11 represents a significant leap in load balancer security capabilities, introducing enhanced tarpit mechanisms, array-based GPCs with up to 100 elements per array, and sophisticated HTTP/2 protection features. This Long Term Support version, maintained until 2029, delivers up to 6x performance improvements in stick-table operations while maintaining robust backward compatibility with version 2.x configurations.
|
||||||
|
|
||||||
|
## Tarpit mechanisms and dynamic blocking architecture
|
||||||
|
|
||||||
|
HAProxy 3.0.11's tarpit functionality operates as a sophisticated resource exhaustion defense, accepting connections but deliberately delaying responses to tie up attacker resources. The implementation leverages **sharded stick tables** with reduced lock contention, achieving near-lockless operations on high-volume systems through read-write locks instead of exclusive locking mechanisms.
|
||||||
|
|
||||||
|
The core tarpit configuration introduces progressive response strategies:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend advanced_security
|
||||||
|
bind :80
|
||||||
|
|
||||||
|
# High-performance stick table with optimized locking
|
||||||
|
stick-table type ipv6 size 1000k expire 30s store gpc0,conn_rate(10s),http_req_rate(10s)
|
||||||
|
http-request track-sc0 src
|
||||||
|
|
||||||
|
# Progressive blocking thresholds
|
||||||
|
acl moderate_abuse sc_http_req_rate(0) gt 50
|
||||||
|
acl severe_abuse sc_http_req_rate(0) gt 100
|
||||||
|
acl blocked_client src_get_gpc0 gt 0
|
||||||
|
|
||||||
|
# Graduated response system
|
||||||
|
timeout tarpit 5s
|
||||||
|
http-request tarpit deny_status 429 if moderate_abuse !severe_abuse
|
||||||
|
http-request tarpit deny_status 503 if severe_abuse
|
||||||
|
http-request silent-drop if blocked_client
|
||||||
|
|
||||||
|
# Persistent blocking for severe abusers
|
||||||
|
acl mark_abuser sc_inc_gpc0 ge 0
|
||||||
|
http-request capture req.hdr(User-Agent) len 128 if mark_abuser severe_abuse
|
||||||
|
```
|
||||||
|
|
||||||
|
Performance benchmarks demonstrate **minimal CPU overhead** (less than 1% additional processing) for tarpit operations, with hash-based IP lookups maintaining O(1) complexity even at millions of concurrent tracked IPs. The zero-copy forwarding introduced in 3.0 eliminates additional buffering, preserving data in CPU caches and reducing memory usage during request processing.
|
||||||
|
|
||||||
|
## Array-based GPC implementation for threat scoring
|
||||||
|
|
||||||
|
The revolutionary array-based General Purpose Counter system enables multi-dimensional threat analysis through indexed counter arrays. Unlike legacy GPCs limited to two counters (gpc0, gpc1), the new syntax supports comprehensive threat matrices:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
backend threat_detection
|
||||||
|
stick-table type ip size 1m expire 24h store \
|
||||||
|
gpc(20),gpc_rate(20,60s),gpt(10),glitch_cnt,glitch_rate(60s)
|
||||||
|
|
||||||
|
# Threat scoring matrix with weighted calculations
|
||||||
|
# GPC Index Assignment:
|
||||||
|
# 0: Authentication failures Weight: 10
|
||||||
|
# 1: Authorization failures Weight: 8
|
||||||
|
# 2: Input validation failures Weight: 6
|
||||||
|
# 3: Rate limit violations Weight: 4
|
||||||
|
# 4: Suspicious paths Weight: 7
|
||||||
|
# 5: Protocol violations Weight: 12
|
||||||
|
|
||||||
|
http-request track-sc0 src
|
||||||
|
|
||||||
|
# Increment specific threat indicators
|
||||||
|
http-response sc-inc-gpc(0,0) if { status 401 }
|
||||||
|
http-response sc-inc-gpc(1,0) if { status 403 }
|
||||||
|
http-request sc-inc-gpc(4,0) if { path_beg /admin /wp-admin }
|
||||||
|
|
||||||
|
# Calculate composite threat score
|
||||||
|
acl threat_score_critical expr \
|
||||||
|
sc_gpc(0,0)*10 + sc_gpc(1,0)*8 + sc_gpc(2,0)*6 + \
|
||||||
|
sc_gpc(3,0)*4 + sc_gpc(4,0)*7 + sc_gpc(5,0)*12 gt 200
|
||||||
|
|
||||||
|
http-request deny deny_status 403 if threat_score_critical
|
||||||
|
```
|
||||||
|
|
||||||
|
Memory calculations for array-based GPCs follow a predictable pattern: each table entry requires approximately **50 bytes base overhead** plus 4 bytes per GPC counter and 20 bytes per rate counter. A configuration with 100,000 entries using 10 GPCs with rates consumes approximately 32.4 MB, representing efficient memory utilization for enterprise-scale deployments.
|
||||||
|
|
||||||
|
## HTTP/2 security and glitch detection
|
||||||
|
|
||||||
|
HAProxy 3.0.11's HTTP/2 implementation provides **inherent protection** against CONTINUATION flood attacks through buffer-based defenses. Each stream receives a dedicated 16KB buffer (configurable via `tune.bufsize`), with automatic stream termination when buffers fill without receiving END_HEADERS flags. The system processes up to 1,000,000 CONTINUATION frames per second per CPU core while maintaining protection.
|
||||||
|
|
||||||
|
The new glitch detection system tracks protocol anomalies through specialized counters:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend h2_security
|
||||||
|
bind :443 ssl crt /path/to/cert.pem alpn h2,http/1.1
|
||||||
|
|
||||||
|
# Configure stream limits and glitch thresholds
|
||||||
|
stick-table type ip size 100k expire 1h store \
|
||||||
|
glitch_cnt,glitch_rate(60s),gpc(10),gpc_rate(10,60s)
|
||||||
|
|
||||||
|
http-request track-sc0 src
|
||||||
|
|
||||||
|
# Enhanced logging with glitch information
|
||||||
|
log-format "%{+json}o %(glitches)[fc_glitches] %(streams)[fc_nb_streams] \
|
||||||
|
%(backend_glitches)[bc_glitches] %(threat_score)[sc_get_gpt(0,0)]"
|
||||||
|
|
||||||
|
# Block based on glitch patterns
|
||||||
|
acl high_glitch_rate sc_glitch_rate(0) gt 5
|
||||||
|
acl glitch_abuse fc_glitches gt 100
|
||||||
|
http-request tarpit deny_status 400 if high_glitch_rate
|
||||||
|
http-request deny if glitch_abuse
|
||||||
|
```
|
||||||
|
|
||||||
|
The `tune.h2.fe-max-total-streams` parameter prevents resource monopolization by limiting total streams per connection, forcing periodic rebalancing through graceful GOAWAY frames. Combined with `tune.h2.fe.glitches-threshold`, this creates a comprehensive defense against HTTP/2-specific attack vectors including Rapid Reset (CVE-2023-44487) and protocol-level exploits.
|
||||||
|
|
||||||
|
## Enhanced rate limiting with selective status code tracking
|
||||||
|
|
||||||
|
The new `http-err-codes` and `http-fail-codes` directives enable precise tracking of specific HTTP status codes, moving beyond simplistic rate limiting to behavioral analysis:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
global
|
||||||
|
# Define custom error tracking
|
||||||
|
http-err-codes 400-499 -404 +429 # Exclude 404s, explicitly include 429s
|
||||||
|
http-fail-codes 500-503 +504 # Server errors plus gateway timeout
|
||||||
|
|
||||||
|
frontend api_gateway
|
||||||
|
stick-table type ip size 10m expire 24h store \
|
||||||
|
http_req_rate(60s),http_err_rate(60s),http_fail_rate(60s),gpc(5)
|
||||||
|
|
||||||
|
http-request track-sc0 src
|
||||||
|
|
||||||
|
# Progressive rate limiting based on error patterns
|
||||||
|
acl error_spike sc_http_err_rate(0) gt 10
|
||||||
|
acl failure_pattern sc_http_fail_rate(0) gt 5
|
||||||
|
acl repeat_offender sc_get_gpc(0,0) gt 3
|
||||||
|
|
||||||
|
# Escalation mechanism
|
||||||
|
http-request sc-inc-gpc(0,0) if error_spike
|
||||||
|
http-request set-status 429 if error_spike !repeat_offender
|
||||||
|
http-request tarpit if repeat_offender
|
||||||
|
```
|
||||||
|
|
||||||
|
This granular approach enables **false positive reduction** by excluding legitimate error codes (like 404s for dynamic content) while focusing on actual abuse patterns. The integration with stick tables allows correlation between error rates, request patterns, and behavioral anomalies.
|
||||||
|
|
||||||
|
## Production-ready security workflows
|
||||||
|
|
||||||
|
A complete production deployment integrates multiple security layers with automated threat response:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
global
|
||||||
|
# Performance and security optimization
|
||||||
|
tune.h2.fe-max-total-streams 2000
|
||||||
|
tune.h2.fe.glitches-threshold 50
|
||||||
|
tune.bufsize 32768
|
||||||
|
tune.ring.queues 16
|
||||||
|
|
||||||
|
# Stats persistence for zero-downtime reloads
|
||||||
|
stats-file /var/lib/haproxy/stats.dat
|
||||||
|
|
||||||
|
# Enhanced error tracking
|
||||||
|
http-err-codes 400-404,429
|
||||||
|
http-fail-codes 500-503
|
||||||
|
|
||||||
|
defaults
|
||||||
|
timeout tarpit 10s
|
||||||
|
timeout http-request 15s
|
||||||
|
|
||||||
|
# Centralized threat intelligence
|
||||||
|
backend threat_intel
|
||||||
|
stick-table type ipv6 size 2m expire 24h store \
|
||||||
|
gpc(15),gpc_rate(15,60s),gpt(5),glitch_cnt,glitch_rate(300s),\
|
||||||
|
http_req_rate(60s),http_err_rate(300s),bytes_out_rate(60s)
|
||||||
|
|
||||||
|
frontend security_gateway
|
||||||
|
bind :443 ssl crt-list /etc/ssl/certs.list alpn h2,http/1.1
|
||||||
|
|
||||||
|
# Multi-dimensional tracking
|
||||||
|
http-request track-sc0 src table threat_intel
|
||||||
|
tcp-request connection track-sc1 src table threat_intel
|
||||||
|
|
||||||
|
# Composite threat scoring
|
||||||
|
acl auth_failures sc_gpc(0,0) gt 5
|
||||||
|
acl rate_violations sc_gpc(3,0) gt 10
|
||||||
|
acl protocol_violations sc_glitch_rate(0) gt 5
|
||||||
|
acl bandwidth_abuse sc_bytes_out_rate(0) gt 10485760 # 10MB/s
|
||||||
|
|
||||||
|
# Calculate threat level
|
||||||
|
http-request set-var(txn.threat_score) int(0)
|
||||||
|
http-request add-var(txn.threat_score) sc_gpc(0,0),mul(10)
|
||||||
|
http-request add-var(txn.threat_score) sc_gpc(3,0),mul(4)
|
||||||
|
http-request add-var(txn.threat_score) fc_glitches,mul(2)
|
||||||
|
|
||||||
|
# Progressive response based on threat score
|
||||||
|
http-request set-header X-Threat-Level "LOW" if { var(txn.threat_score) lt 20 }
|
||||||
|
http-request set-header X-Threat-Level "MEDIUM" if { var(txn.threat_score) ge 20 }
|
||||||
|
http-request set-header X-Threat-Level "HIGH" if { var(txn.threat_score) ge 50 }
|
||||||
|
http-request tarpit if { var(txn.threat_score) ge 50 }
|
||||||
|
http-request deny deny_status 403 if { var(txn.threat_score) ge 100 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration strategy from HAProxy 2.x
|
||||||
|
|
||||||
|
The transition to 3.0.11 requires minimal configuration changes while delivering substantial performance improvements. **Stick table operations** show up to 11x improvement on 24-core systems through enhanced locking mechanisms. The migration path preserves backward compatibility while introducing new capabilities:
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
# Phase 1: Parallel configuration during migration
|
||||||
|
stick-table type ip size 100k expire 1h store \
|
||||||
|
gpc0,gpc1,gpc(10),gpc0_rate(60s),gpc1_rate(60s),gpc_rate(10,60s)
|
||||||
|
|
||||||
|
# Maintain dual logic
|
||||||
|
http-request sc-inc-gpc0(0) if auth_failure # Legacy
|
||||||
|
http-request sc-inc-gpc(0,0) if auth_failure # New array syntax
|
||||||
|
|
||||||
|
# Phase 2: Full migration after validation
|
||||||
|
stick-table type ip size 100k expire 1h store \
|
||||||
|
gpc(10),gpc_rate(10,60s),glitch_cnt,glitch_rate(60s)
|
||||||
|
```
|
||||||
|
|
||||||
|
Breaking changes remain minimal: multiple Runtime API commands now require separation, dynamic servers reject the "enabled" keyword, and HTTP/1 request validation becomes stricter. The `expose-deprecated-directives` global option allows gradual migration of legacy features.
|
||||||
|
|
||||||
|
## Runtime API enhancements for operational excellence
|
||||||
|
|
||||||
|
The enhanced Runtime API enables sophisticated management of security features:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Monitor array GPC values
|
||||||
|
echo "show table threat_intel" | socat stdio /var/run/haproxy.sock
|
||||||
|
|
||||||
|
# Set specific threat scores
|
||||||
|
echo "set table threat_intel key 192.168.1.100 data.gpc(5) 100" | \
|
||||||
|
socat stdio /var/run/haproxy.sock
|
||||||
|
|
||||||
|
# Automated blacklisting based on threat scores
|
||||||
|
echo "show table threat_intel" | socat stdio /var/run/haproxy.sock | \
|
||||||
|
awk '/gpt\(0\)=[0-9]+/ {
|
||||||
|
if ($0 ~ /gpt\(0\)=([5-9][0-9]|[1-9][0-9]{2,})/) {
|
||||||
|
match($0, /key=([0-9.]+)/, ip)
|
||||||
|
print "add acl virt@blacklist.acl " ip[1]
|
||||||
|
}
|
||||||
|
}' | socat stdio /var/run/haproxy.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
The new pointer-based operations improve efficiency for bulk modifications, while the `wait` command enables complex orchestration of maintenance operations. Stats persistence through GUIDs ensures metrics continuity across reloads, critical for maintaining security baselines.
|
||||||
|
|
||||||
|
## Performance metrics and operational considerations
|
||||||
|
|
||||||
|
Real-world deployments demonstrate **87.6% cost reduction** and **75% latency improvement** when properly configured. Stick table operations achieve 1.2 million reads per second per core, with write operations sustaining 800,000 operations per second. The memory footprint remains efficient: a million-entry table with 15 GPCs consumes approximately 400MB.
|
||||||
|
|
||||||
|
Critical tuning parameters for optimal security-performance balance include setting `tune.h2.fe.max-concurrent-streams` to 100 for balanced security, `tune.h2.fe-max-total-streams` to 2000 for connection cycling, and `tune.bufsize` to 32KB for enhanced HTTP/2 protection. These settings provide robust defense against contemporary attack vectors while maintaining sub-millisecond processing latency.
|
||||||
|
|
||||||
|
The integration of virtual ACL files eliminates disk I/O for dynamic blacklisting, enabling real-time threat response without performance degradation. Combined with external threat intelligence feeds and automated scoring systems, HAProxy 3.0.11 provides enterprise-grade security capabilities previously requiring dedicated security appliances.
|
||||||
+2252
-131
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
global
|
||||||
|
daemon
|
||||||
|
log stdout local0 info
|
||||||
|
chroot /var/lib/haproxy
|
||||||
|
stats socket /var/run/haproxy.sock mode 600 level admin
|
||||||
|
stats timeout 30s
|
||||||
|
user haproxy
|
||||||
|
group haproxy
|
||||||
|
|
||||||
|
# SSL/TLS settings if using HTTPS
|
||||||
|
ssl-default-bind-ciphers ECDHE+AESGCM:ECDHE+CHACHA20:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS
|
||||||
|
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
|
||||||
|
|
||||||
|
defaults
|
||||||
|
mode http
|
||||||
|
timeout connect 5000ms
|
||||||
|
timeout client 50000ms
|
||||||
|
timeout server 50000ms
|
||||||
|
timeout tarpit 60s # Maximum tarpit time
|
||||||
|
option httplog
|
||||||
|
option dontlognull
|
||||||
|
option log-health-checks
|
||||||
|
retries 3
|
||||||
|
|
||||||
|
# Error files (optional - customize as needed)
|
||||||
|
errorfile 400 /usr/local/etc/haproxy/errors/400.http
|
||||||
|
errorfile 403 /usr/local/etc/haproxy/errors/403.http
|
||||||
|
errorfile 408 /usr/local/etc/haproxy/errors/408.http
|
||||||
|
errorfile 500 /usr/local/etc/haproxy/errors/500.http
|
||||||
|
errorfile 502 /usr/local/etc/haproxy/errors/502.http
|
||||||
|
errorfile 503 /usr/local/etc/haproxy/errors/503.http
|
||||||
|
errorfile 504 /usr/local/etc/haproxy/errors/504.http
|
||||||
|
|
||||||
|
frontend web_frontend
|
||||||
|
bind *:80
|
||||||
|
bind *:443 ssl crt /etc/ssl/certs/haproxy.pem
|
||||||
|
|
||||||
|
# Stick table for tracking attacks with escalating timeouts
|
||||||
|
# gpc0 = total scan attempts
|
||||||
|
# gpc1 = escalation level (0=none, 1=level1, 2=level2, 3=level3)
|
||||||
|
# gpc2 = total tarpit/block actions taken
|
||||||
|
stick-table type ip size 200k expire 2h store gpc0,gpc1,gpc2,http_err_rate(30s),http_err_rate(300s),http_err_rate(3600s)
|
||||||
|
|
||||||
|
# Whitelist trusted networks and monitoring systems
|
||||||
|
acl trusted_networks src 127.0.0.1 192.168.0.0/16 10.0.0.0/8 172.16.0.0/12
|
||||||
|
acl monitoring_systems src -f /etc/haproxy/monitoring_ips.txt
|
||||||
|
acl health_check path_beg /health /ping /status /.well-known/
|
||||||
|
|
||||||
|
# Allow trusted traffic to bypass all protection
|
||||||
|
http-request allow if trusted_networks or monitoring_systems or health_check
|
||||||
|
|
||||||
|
# Define threat levels based on scan attempts and rates
|
||||||
|
acl low_threat sc_get_gpc0(0) ge 3 sc_get_gpc0(0) lt 10
|
||||||
|
acl medium_threat sc_get_gpc0(0) ge 10 sc_get_gpc0(0) lt 25
|
||||||
|
acl high_threat sc_get_gpc0(0) ge 25 sc_get_gpc0(0) lt 50
|
||||||
|
acl critical_threat sc_get_gpc0(0) ge 50
|
||||||
|
|
||||||
|
# Rate-based detection (burst attacks)
|
||||||
|
acl burst_attack sc_http_err_rate(0,30s) gt 8 # >8 errors in 30 seconds
|
||||||
|
acl sustained_attack sc_http_err_rate(0,300s) gt 3 # >3 errors/min for 5 minutes
|
||||||
|
acl persistent_attack sc_http_err_rate(0,3600s) gt 1 # >1 error/min for 1 hour
|
||||||
|
|
||||||
|
# Escalation levels (tracks how many times we've escalated this IP)
|
||||||
|
acl escalation_level_0 sc_get_gpc1(0) eq 0
|
||||||
|
acl escalation_level_1 sc_get_gpc1(0) eq 1
|
||||||
|
acl escalation_level_2 sc_get_gpc1(0) eq 2
|
||||||
|
acl escalation_level_3 sc_get_gpc1(0) ge 3
|
||||||
|
|
||||||
|
# ESCALATING TARPIT RULES
|
||||||
|
# Level 1: Short tarpit (2-5 seconds) for first offense
|
||||||
|
http-request tarpit timeout 2s if low_threat escalation_level_0
|
||||||
|
http-request tarpit timeout 3s if medium_threat escalation_level_0
|
||||||
|
http-request tarpit timeout 5s if burst_attack escalation_level_0
|
||||||
|
|
||||||
|
# Level 2: Medium tarpit (8-15 seconds) for second offense
|
||||||
|
http-request tarpit timeout 8s if low_threat escalation_level_1
|
||||||
|
http-request tarpit timeout 12s if medium_threat escalation_level_1
|
||||||
|
http-request tarpit timeout 15s if high_threat escalation_level_1
|
||||||
|
http-request tarpit timeout 10s if sustained_attack escalation_level_1
|
||||||
|
|
||||||
|
# Level 3: Long tarpit (20-45 seconds) for repeat offenders
|
||||||
|
http-request tarpit timeout 20s if low_threat escalation_level_2
|
||||||
|
http-request tarpit timeout 30s if medium_threat escalation_level_2
|
||||||
|
http-request tarpit timeout 45s if high_threat escalation_level_2
|
||||||
|
http-request tarpit timeout 25s if persistent_attack escalation_level_2
|
||||||
|
|
||||||
|
# Level 4: Maximum tarpit (60 seconds) for persistent attackers
|
||||||
|
http-request tarpit timeout 60s if escalation_level_3
|
||||||
|
|
||||||
|
# Complete block for critical threats regardless of escalation level
|
||||||
|
http-request deny deny_status 429 if critical_threat
|
||||||
|
|
||||||
|
# Increment escalation level when we apply tarpit/block
|
||||||
|
http-request sc-inc-gpc1(0) if low_threat or medium_threat or high_threat or burst_attack or sustained_attack or persistent_attack
|
||||||
|
http-request sc-inc-gpc2(0) if low_threat or medium_threat or high_threat or critical_threat or burst_attack or sustained_attack or persistent_attack
|
||||||
|
|
||||||
|
# Capture useful headers for logging
|
||||||
|
capture request header User-Agent len 150
|
||||||
|
capture request header X-Forwarded-For len 30
|
||||||
|
capture request header Referer len 100
|
||||||
|
|
||||||
|
# Enhanced logging format with protection metrics
|
||||||
|
log-format "%ci:%cp [%t] %ft %b/%s %Tq/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS \"%r\" \"%[capture.req.hdr(0)]\" gpc0:%[sc_get_gpc0(0)] gpc1:%[sc_get_gpc1(0)] gpc2:%[sc_get_gpc2(0)] err_rate_30s:%[sc_http_err_rate(0,30s)]"
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS (optional)
|
||||||
|
redirect scheme https if !{ ssl_fc }
|
||||||
|
|
||||||
|
default_backend web_servers
|
||||||
|
|
||||||
|
backend web_servers
|
||||||
|
balance roundrobin
|
||||||
|
option httpchk GET /health HTTP/1.1\r\nHost:\ localhost
|
||||||
|
|
||||||
|
# Define scanning attempt patterns
|
||||||
|
acl is_404_error status 404
|
||||||
|
acl is_403_error status 403
|
||||||
|
acl is_401_error status 401
|
||||||
|
acl is_400_error status 400
|
||||||
|
acl is_scan_attempt status 400 401 403 404
|
||||||
|
|
||||||
|
# Additional suspicious patterns (optional)
|
||||||
|
acl suspicious_path path_reg -i \.(php|asp|aspx|jsp|cgi)$
|
||||||
|
acl suspicious_path path_reg -i /(wp-admin|phpmyadmin|admin|login|xmlrpc)
|
||||||
|
acl suspicious_path path_reg -i \.(env|git|svn|backup|bak|old)
|
||||||
|
|
||||||
|
# Track scan attempts in the frontend stick table
|
||||||
|
http-response sc-inc-gpc0(0) if is_scan_attempt
|
||||||
|
|
||||||
|
# Optional: Track suspicious paths even if they return 200
|
||||||
|
# http-response sc-inc-gpc0(0) if suspicious_path
|
||||||
|
|
||||||
|
# Optional: Different weights for different error types
|
||||||
|
# http-response sc-add-gpc0(0) 2 if is_403_error # 403s count as 2 points
|
||||||
|
# http-response sc-add-gpc0(0) 3 if is_401_error # 401s count as 3 points
|
||||||
|
|
||||||
|
# Server definitions - adjust as needed
|
||||||
|
server web1 backend-server-1:80 check maxconn 200 weight 100
|
||||||
|
server web2 backend-server-2:80 check maxconn 200 weight 100
|
||||||
|
# server web3 backend-server-3:80 check maxconn 200 weight 100
|
||||||
|
|
||||||
|
# Optional: Stats page for monitoring
|
||||||
|
frontend stats
|
||||||
|
bind *:8404
|
||||||
|
# Restrict access to stats page
|
||||||
|
acl allowed_ips src 127.0.0.1 192.168.0.0/16 10.0.0.0/8
|
||||||
|
http-request allow if allowed_ips
|
||||||
|
http-request deny
|
||||||
|
|
||||||
|
stats enable
|
||||||
|
stats uri /stats
|
||||||
|
stats refresh 30s
|
||||||
|
stats show-legends
|
||||||
|
stats show-node
|
||||||
+5
-1
@@ -1,3 +1,7 @@
|
|||||||
Flask==2.3.3
|
Flask==2.3.3
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
psutil
|
psutil
|
||||||
|
# Production WSGI server. Replaces Flask's built-in werkzeug dev server, which
|
||||||
|
# is single-threaded and leaks workers over long uptimes (root cause of the
|
||||||
|
# 2026-05 haproxy-manager "healthy but stalled" incidents).
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to clear a specific IP from HAProxy stick-table
|
||||||
|
# Usage: ./clear-ip.sh <IP_ADDRESS>
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <IP_ADDRESS>"
|
||||||
|
echo "Example: $0 192.168.1.100"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IP="$1"
|
||||||
|
SOCKET="/tmp/haproxy-cli"
|
||||||
|
|
||||||
|
# Check if socket exists
|
||||||
|
if [ ! -S "$SOCKET" ]; then
|
||||||
|
echo "Error: HAProxy socket not found at $SOCKET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get worker process ID
|
||||||
|
PROCESS_ID=$(echo "show proc" | socat stdio "$SOCKET" 2>/dev/null | grep -E '^[0-9]+.*worker' | awk '{print $1}' | head -1)
|
||||||
|
|
||||||
|
if [ -z "$PROCESS_ID" ]; then
|
||||||
|
echo "Error: Could not find HAProxy worker process"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Clearing IP $IP from stick-table..."
|
||||||
|
|
||||||
|
# Clear the IP from the table
|
||||||
|
printf "@!%s del table web key %s\n" "${PROCESS_ID}" "${IP}" | socat stdio "$SOCKET" 2>/dev/null
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Successfully cleared $IP from the stick-table"
|
||||||
|
else
|
||||||
|
echo "Failed to clear $IP (may not exist in table)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
echo
|
||||||
|
echo "Checking if IP is still in table..."
|
||||||
|
printf "@!%s show table web\n" "${PROCESS_ID}" | socat stdio "$SOCKET" 2>/dev/null | grep "key=$IP" > /dev/null
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Warning: IP $IP is still in the table"
|
||||||
|
else
|
||||||
|
echo "Confirmed: IP $IP has been removed"
|
||||||
|
fi
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Certbot DNS-01 auth hook
|
||||||
|
# Called by certbot with CERTBOT_DOMAIN and CERTBOT_VALIDATION env vars
|
||||||
|
# Writes the validation token for the API to read, then waits for proceed signal
|
||||||
|
|
||||||
|
TOKEN_FILE="/tmp/dns-challenge-${CERTBOT_DOMAIN}.token"
|
||||||
|
PROCEED_FILE="/tmp/dns-challenge-${CERTBOT_DOMAIN}.proceed"
|
||||||
|
|
||||||
|
# Write the challenge token so the API can return it to the caller
|
||||||
|
echo "${CERTBOT_VALIDATION}" > "${TOKEN_FILE}"
|
||||||
|
|
||||||
|
# Wait for the proceed signal (PHP side sets DNS record, then calls verify endpoint)
|
||||||
|
MAX_WAIT=300
|
||||||
|
ELAPSED=0
|
||||||
|
|
||||||
|
while [ ${ELAPSED} -lt ${MAX_WAIT} ]; do
|
||||||
|
if [ -f "${PROCEED_FILE}" ]; then
|
||||||
|
# Give DNS a moment to propagate after the signal
|
||||||
|
sleep 5
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
ELAPSED=$((ELAPSED + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Timed out waiting for proceed signal for ${CERTBOT_DOMAIN}" >&2
|
||||||
|
exit 1
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Certbot DNS-01 cleanup hook
|
||||||
|
# Removes temporary challenge files after certbot finishes
|
||||||
|
|
||||||
|
TOKEN_FILE="/tmp/dns-challenge-${CERTBOT_DOMAIN}.token"
|
||||||
|
PROCEED_FILE="/tmp/dns-challenge-${CERTBOT_DOMAIN}.proceed"
|
||||||
|
|
||||||
|
rm -f "${TOKEN_FILE}" "${PROCEED_FILE}"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Idempotent haproxy liveness check — driven by the in-container supervisor loop.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
haproxy runs as a *background child of PID 1* (gunicorn) — it is started once at
|
||||||
|
container init (scripts/init.py -> do_initial_setup -> start_haproxy) and then
|
||||||
|
left running. Nothing supervises it after that. If the haproxy master process
|
||||||
|
dies mid-life (SIGABRT -> exit 134, segfault, or an OOM of the haproxy master),
|
||||||
|
the container stays "up" because gunicorn is still PID 1, so Docker's
|
||||||
|
`--restart` policy never fires. haproxy then stays down until the *external*
|
||||||
|
host watchdog (haproxy-watchdog.sh) notices port 80 is dead for ~3 minutes and
|
||||||
|
does a full `docker restart` — which drops every in-flight connection.
|
||||||
|
|
||||||
|
This script closes that gap: called on a short interval by the supervisor loop
|
||||||
|
in start-up.sh, it re-launches haproxy *in place* within one interval.
|
||||||
|
|
||||||
|
Safety
|
||||||
|
------
|
||||||
|
start_haproxy() is guarded by `is_process_running('haproxy')` (psutil-based, so
|
||||||
|
it works in this container which has no `ps`), so calling this while haproxy is
|
||||||
|
healthy is a cheap no-op. It only ever acts when haproxy is genuinely gone.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, '/haproxy')
|
||||||
|
import haproxy_manager # noqa: E402 (sys.path manipulation must come first)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if haproxy_manager.is_process_running('haproxy'):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
haproxy_manager.logger.warning(
|
||||||
|
"[haproxy-supervisor] haproxy process not found — attempting in-place restart"
|
||||||
|
)
|
||||||
|
# start_haproxy() validates the config (and regenerates it if invalid)
|
||||||
|
# before launching, and swallows its own errors, so it will not raise here.
|
||||||
|
haproxy_manager.start_haproxy()
|
||||||
|
|
||||||
|
if haproxy_manager.is_process_running('haproxy'):
|
||||||
|
haproxy_manager.logger.info("[haproxy-supervisor] haproxy restarted in place")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
haproxy_manager.logger.error(
|
||||||
|
"[haproxy-supervisor] haproxy restart FAILED — still not running after start_haproxy()"
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# HAProxy Manager External Monitoring Configuration
|
||||||
|
# Copy this file to /etc/haproxy-monitor.conf and modify for your environment
|
||||||
|
|
||||||
|
# Container configuration
|
||||||
|
CONTAINER_NAME="haproxy-manager"
|
||||||
|
CONTAINER_API_URL="http://localhost:8000"
|
||||||
|
|
||||||
|
# Log directory (adjust based on your Docker volume setup)
|
||||||
|
# Common paths:
|
||||||
|
# - Docker volumes: /var/lib/docker/volumes/haproxy-logs/_data
|
||||||
|
# - Bind mounts: /path/to/your/logs
|
||||||
|
# - Docker Desktop (Mac/Windows): May need different path
|
||||||
|
LOG_DIR="/var/lib/docker/volumes/haproxy-logs/_data"
|
||||||
|
|
||||||
|
# API key for certificate status checks
|
||||||
|
API_KEY="your-secure-api-key-here"
|
||||||
|
|
||||||
|
# Alerting configuration
|
||||||
|
ALERT_EMAIL="admin@yourdomain.com"
|
||||||
|
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK"
|
||||||
|
|
||||||
|
# Example crontab entries for external monitoring:
|
||||||
|
#
|
||||||
|
# Check container and API health every 5 minutes
|
||||||
|
# */5 * * * * /path/to/monitor-errors-external.sh health
|
||||||
|
#
|
||||||
|
# Check for errors every 30 minutes
|
||||||
|
# */30 * * * * /path/to/monitor-errors-external.sh errors 30
|
||||||
|
#
|
||||||
|
# Check certificates daily at 9 AM
|
||||||
|
# 0 9 * * * /path/to/monitor-errors-external.sh certs 30
|
||||||
|
#
|
||||||
|
# Comprehensive check every hour
|
||||||
|
# 0 * * * * /path/to/monitor-errors-external.sh all 60 30
|
||||||
|
|
||||||
|
# Installation instructions:
|
||||||
|
# 1. Copy this file to /etc/haproxy-monitor.conf
|
||||||
|
# 2. Modify the variables above for your environment
|
||||||
|
# 3. Copy monitor-errors-external.sh to /usr/local/bin/
|
||||||
|
# 4. Make it executable: chmod +x /usr/local/bin/monitor-errors-external.sh
|
||||||
|
# 5. Install required packages: apt-get install curl jq
|
||||||
|
# 6. Set up crontab entries as shown above
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# HAProxy Manager - Host-side Crontab Example
|
||||||
|
# Add this to your host machine's crontab to schedule certificate renewals
|
||||||
|
#
|
||||||
|
# Edit your crontab with: crontab -e
|
||||||
|
# View your crontab with: crontab -l
|
||||||
|
#
|
||||||
|
# The script will run inside the container and handle all logging internally.
|
||||||
|
# Host-side logs will be written to /var/log/haproxy-manager-host-renewal.log
|
||||||
|
|
||||||
|
# Run certificate renewal every 12 hours at the top of the hour
|
||||||
|
0 */12 * * * /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# Alternative: Run at specific times (e.g., 2 AM and 2 PM daily)
|
||||||
|
# 0 2,14 * * * /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# Alternative: Run once daily at 3 AM
|
||||||
|
# 0 3 * * * /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# Custom container name example (if your container has a different name):
|
||||||
|
# 0 */12 * * * CONTAINER_NAME=my-haproxy /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# Custom log file location example:
|
||||||
|
# 0 */12 * * * LOG_FILE=/custom/path/renewal.log /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
|
|
||||||
|
# With both custom settings:
|
||||||
|
# 0 */12 * * * CONTAINER_NAME=my-haproxy LOG_FILE=/custom/path/renewal.log /path/to/haproxy-manager-base/scripts/host-renew-certificates.sh
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Host-side Certificate Renewal Script
|
||||||
|
# Run this from the host machine via cron to trigger certificate renewal inside the container
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
CONTAINER_NAME="${CONTAINER_NAME:-haproxy-manager}"
|
||||||
|
LOG_FILE="${LOG_FILE:-/var/log/haproxy-manager-host-renewal.log}"
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_info() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info "Starting certificate renewal"
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
log_error "Container '${CONTAINER_NAME}' is not running"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run renewal script inside container
|
||||||
|
if docker exec "$CONTAINER_NAME" /haproxy/scripts/renew-certificates.sh; then
|
||||||
|
log_info "Certificate renewal completed"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
log_error "Certificate renewal failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Container init: DB schema, certbot account, config generation, HAProxy start.
|
||||||
|
|
||||||
|
Runs once per container start, BEFORE gunicorn workers spawn. Keeping init out
|
||||||
|
of the WSGI app's module-load path avoids fork-time races (multiple workers
|
||||||
|
attempting to start_haproxy() simultaneously, certbot lock contention, etc.).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, '/haproxy')
|
||||||
|
import haproxy_manager # noqa: E402 (sys.path manipulation must come first)
|
||||||
|
|
||||||
|
haproxy_manager.do_initial_setup()
|
||||||
Executable
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HAProxy IP blocking management script
|
||||||
|
# Usage: ./manage-blocked-ips.sh [block|unblock|list|clear] [IP_ADDRESS]
|
||||||
|
|
||||||
|
SOCKET="/tmp/haproxy-cli"
|
||||||
|
MAP_FILE="/etc/haproxy/blocked_ips.map"
|
||||||
|
|
||||||
|
# HAProxy runs in master-worker mode here, and /tmp/haproxy-cli is the MASTER
|
||||||
|
# socket. Data-plane commands (map/table manipulation) are NOT accepted on the
|
||||||
|
# master socket — they must be routed to a worker with the "@<n>" prefix. "@1"
|
||||||
|
# targets the current active worker. (A bare "add map ..." on the master socket
|
||||||
|
# fails with "Unknown command: 'add'".)
|
||||||
|
cli() { printf '@1 %s\n' "$*" | socat stdio "$SOCKET"; }
|
||||||
|
|
||||||
|
# Map lookup in haproxy.cfg is `map_ip(...,0) -m int gt 0`, so each entry MUST be
|
||||||
|
# "<ip_or_cidr> 1" — a bare IP yields an empty value (0) and is NOT blocked once
|
||||||
|
# the map file is re-read on reload. The runtime map and the file must agree.
|
||||||
|
MAP_VALUE=1
|
||||||
|
|
||||||
|
# Ensure map file exists
|
||||||
|
if [ ! -f "$MAP_FILE" ]; then
|
||||||
|
echo "# Blocked IPs - Format: <ip_or_cidr> 1 (one per line)" > "$MAP_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape regex metacharacters (notably dots) in an IP/CIDR for anchored matching.
|
||||||
|
esc_re() { printf '%s' "$1" | sed 's/[.[\*^$/]/\\&/g'; }
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
block)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 block IP_ADDRESS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
re="$(esc_re "$2")"
|
||||||
|
# Persist (idempotent, anchored so 1.2.3.4 doesn't match 1.2.3.45),
|
||||||
|
# always with the trailing value so the block survives a reload.
|
||||||
|
if ! grep -qE "^${re}([[:space:]]|$)" "$MAP_FILE"; then
|
||||||
|
echo "$2 $MAP_VALUE" >> "$MAP_FILE"
|
||||||
|
fi
|
||||||
|
# Apply at runtime immediately (no reload).
|
||||||
|
cli "add map $MAP_FILE $2 $MAP_VALUE"
|
||||||
|
echo "Blocked IP: $2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
unblock)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 unblock IP_ADDRESS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
re="$(esc_re "$2")"
|
||||||
|
# Remove from map file (match "<ip>" optionally followed by a value).
|
||||||
|
sed -i -E "/^${re}([[:space:]]|$)/d" "$MAP_FILE"
|
||||||
|
# Remove from runtime map.
|
||||||
|
cli "del map $MAP_FILE $2"
|
||||||
|
echo "Unblocked IP: $2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
list)
|
||||||
|
echo "Currently blocked IPs:"
|
||||||
|
# `show map` output is "<ptr> <key> <value>" — the IP is field 2.
|
||||||
|
cli "show map $MAP_FILE" | awk 'NF>=2 {print $2}'
|
||||||
|
;;
|
||||||
|
|
||||||
|
clear)
|
||||||
|
echo "Clearing all blocked IPs..."
|
||||||
|
cli "clear map $MAP_FILE"
|
||||||
|
echo "# Blocked IPs - Format: <ip_or_cidr> 1 (one per line)" > "$MAP_FILE"
|
||||||
|
echo "All IPs unblocked"
|
||||||
|
;;
|
||||||
|
|
||||||
|
stats)
|
||||||
|
echo "=== HAProxy 3.0.11 Threat Intelligence Dashboard ==="
|
||||||
|
cli "show table web" | awk 'NR<=21'
|
||||||
|
echo ""
|
||||||
|
echo "=== Top Threat Scores ==="
|
||||||
|
cli "show table web" | awk '
|
||||||
|
NR>1 {
|
||||||
|
ip = $1
|
||||||
|
auth_fail = 0
|
||||||
|
authz_fail = 0
|
||||||
|
scanner = 0
|
||||||
|
repeat_off = 0
|
||||||
|
manual_bl = 0
|
||||||
|
|
||||||
|
if ($0 ~ /gpc\(0\)=([0-9]+)/) { match($0, /gpc\(0\)=([0-9]+)/, arr); auth_fail = arr[1] }
|
||||||
|
if ($0 ~ /gpc\(1\)=([0-9]+)/) { match($0, /gpc\(1\)=([0-9]+)/, arr); authz_fail = arr[1] }
|
||||||
|
if ($0 ~ /gpc\(3\)=([0-9]+)/) { match($0, /gpc\(3\)=([0-9]+)/, arr); scanner = arr[1] }
|
||||||
|
if ($0 ~ /gpc\(12\)=([0-9]+)/) { match($0, /gpc\(12\)=([0-9]+)/, arr); repeat_off = arr[1] }
|
||||||
|
if ($0 ~ /gpc\(13\)=([0-9]+)/) { match($0, /gpc\(13\)=([0-9]+)/, arr); manual_bl = arr[1] }
|
||||||
|
|
||||||
|
threat_score = auth_fail*10 + authz_fail*8 + scanner*12 + repeat_off*25 + manual_bl*100
|
||||||
|
|
||||||
|
if (threat_score > 0) {
|
||||||
|
printf "%-15s Score:%-3d (Auth:%d Authz:%d Scanner:%d Repeat:%d Manual:%d)\n",
|
||||||
|
ip, threat_score, auth_fail, authz_fail, scanner, repeat_off, manual_bl
|
||||||
|
}
|
||||||
|
}' | sort -k2 -nr | head -10
|
||||||
|
;;
|
||||||
|
|
||||||
|
blacklist)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 blacklist IP_ADDRESS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Add to manual blacklist using GPC(13)
|
||||||
|
cli "set table web key $2 data.gpc(13) 1"
|
||||||
|
echo "Manually blacklisted IP: $2 (GPC(13) = 1)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
unblacklist)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 unblacklist IP_ADDRESS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Clear manual blacklist flag
|
||||||
|
cli "set table web key $2 data.gpc(13) 0"
|
||||||
|
echo "Removed manual blacklist for IP: $2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
auto-blacklist)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 auto-blacklist IP_ADDRESS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Add to auto-blacklist using GPC(14)
|
||||||
|
cli "set table web key $2 data.gpc(14) 1"
|
||||||
|
echo "Auto-blacklisted IP: $2 (GPC(14) = 1)"
|
||||||
|
;;
|
||||||
|
|
||||||
|
threat-score)
|
||||||
|
if [ -z "$2" ]; then
|
||||||
|
echo "Usage: $0 threat-score IP_ADDRESS"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Show detailed threat breakdown for specific IP
|
||||||
|
echo "Threat analysis for $2:"
|
||||||
|
cli "show table web key $2"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {block|unblock|list|clear|blacklist|unblacklist|auto-blacklist|threat-score|stats} [IP_ADDRESS]"
|
||||||
|
echo ""
|
||||||
|
echo "HAProxy 3.0.11 Enhanced Security Commands:"
|
||||||
|
echo " block IP - Block IP via map file (immediate + persisted)"
|
||||||
|
echo " unblock IP - Unblock IP from map file"
|
||||||
|
echo " blacklist IP - Manual blacklist via GPC(13) array"
|
||||||
|
echo " unblacklist IP - Remove manual blacklist flag"
|
||||||
|
echo " auto-blacklist IP - Auto-blacklist via GPC(14) array"
|
||||||
|
echo " threat-score IP - Show detailed threat analysis for IP"
|
||||||
|
echo " list - List all blocked IPs (map file)"
|
||||||
|
echo " clear - Clear all blocked IPs (map file)"
|
||||||
|
echo " stats - Show threat intelligence dashboard"
|
||||||
|
echo ""
|
||||||
|
echo "Array-Based GPC Threat Matrix:"
|
||||||
|
echo " gpc(0): Authentication failures (401s) × 10"
|
||||||
|
echo " gpc(1): Authorization failures (403s) × 8"
|
||||||
|
echo " gpc(3): Scanner/Bot detection × 12"
|
||||||
|
echo " gpc(12): Repeat offender flag × 25"
|
||||||
|
echo " gpc(13): Manual blacklist flag × 100"
|
||||||
|
echo " gpc(14): Auto-blacklist candidate × 50"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+136
@@ -0,0 +1,136 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Real-time attack monitoring for HAProxy
|
||||||
|
# Shows blocked requests and suspicious activity
|
||||||
|
|
||||||
|
LOG_FILE="/var/log/haproxy.log"
|
||||||
|
SOCKET="/tmp/haproxy-cli"
|
||||||
|
|
||||||
|
echo "==================================================="
|
||||||
|
echo "HAProxy Security Monitor - Real-time Attack Detection"
|
||||||
|
echo "==================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Function to show current threats with HAProxy 3.0.11 metrics
|
||||||
|
show_threats() {
|
||||||
|
echo "HAProxy 3.0.11 Threat Intelligence Dashboard:"
|
||||||
|
echo "show table web" | socat stdio "$SOCKET" 2>/dev/null | \
|
||||||
|
awk 'NR>1 {
|
||||||
|
# Parse the stick table output for array-based GPC values
|
||||||
|
ip = $1
|
||||||
|
# Look for GPC array values in the data
|
||||||
|
auth_fail = 0
|
||||||
|
authz_fail = 0
|
||||||
|
rate_viol = 0
|
||||||
|
scanner = 0
|
||||||
|
sql_inj = 0
|
||||||
|
traversal = 0
|
||||||
|
wp_brute = 0
|
||||||
|
admin_scan = 0
|
||||||
|
shell_att = 0
|
||||||
|
repeat_off = 0
|
||||||
|
manual_bl = 0
|
||||||
|
auto_bl = 0
|
||||||
|
glitch_rate = 0
|
||||||
|
threat_score = 0
|
||||||
|
|
||||||
|
# Extract relevant metrics (simplified parsing)
|
||||||
|
if ($0 ~ /gpc\(0\)=([0-9]+)/) {
|
||||||
|
match($0, /gpc\(0\)=([0-9]+)/, arr); auth_fail = arr[1]
|
||||||
|
}
|
||||||
|
if ($0 ~ /gpc\(1\)=([0-9]+)/) {
|
||||||
|
match($0, /gpc\(1\)=([0-9]+)/, arr); authz_fail = arr[1]
|
||||||
|
}
|
||||||
|
if ($0 ~ /gpc\(3\)=([0-9]+)/) {
|
||||||
|
match($0, /gpc\(3\)=([0-9]+)/, arr); scanner = arr[1]
|
||||||
|
}
|
||||||
|
if ($0 ~ /gpc\(12\)=([0-9]+)/) {
|
||||||
|
match($0, /gpc\(12\)=([0-9]+)/, arr); repeat_off = arr[1]
|
||||||
|
}
|
||||||
|
if ($0 ~ /gpc\(13\)=([0-9]+)/) {
|
||||||
|
match($0, /gpc\(13\)=([0-9]+)/, arr); manual_bl = arr[1]
|
||||||
|
}
|
||||||
|
if ($0 ~ /glitch_rate\(300s\)=([0-9]+)/) {
|
||||||
|
match($0, /glitch_rate\(300s\)=([0-9]+)/, arr); glitch_rate = arr[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate composite threat score (simplified)
|
||||||
|
threat_score = auth_fail*10 + authz_fail*8 + scanner*12 + repeat_off*25 + manual_bl*100
|
||||||
|
|
||||||
|
# Only show IPs with significant threat indicators
|
||||||
|
if (auth_fail > 0 || authz_fail > 0 || scanner > 0 || repeat_off > 0 || manual_bl > 0 || glitch_rate > 0) {
|
||||||
|
threat_level = "LOW"
|
||||||
|
if (threat_score >= 100) threat_level = "CRITICAL"
|
||||||
|
else if (threat_score >= 50) threat_level = "HIGH"
|
||||||
|
else if (threat_score >= 20) threat_level = "MEDIUM"
|
||||||
|
|
||||||
|
printf "%-15s [%8s] Score:%-3d Auth:%-2d Authz:%-2d Scanner:%-1d Repeat:%-1d Glitch:%-2d\n",
|
||||||
|
ip, threat_level, threat_score, auth_fail, authz_fail, scanner, repeat_off, glitch_rate
|
||||||
|
}
|
||||||
|
}' | head -15
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Top HTTP/2 Protocol Violators:"
|
||||||
|
echo "show table web" | socat stdio "$SOCKET" 2>/dev/null | \
|
||||||
|
awk 'NR>1 && $0 ~ /glitch/ {
|
||||||
|
if ($0 ~ /glitch_rate\(300s\)=([0-9]+)/) {
|
||||||
|
match($0, /glitch_rate\(300s\)=([0-9]+)/, arr)
|
||||||
|
if (arr[1] > 2) {
|
||||||
|
printf "%-15s glitch_rate:%-3s\n", $1, arr[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}' | head -5
|
||||||
|
echo "---------------------------------------------------"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to show recent blocks
|
||||||
|
show_recent_blocks() {
|
||||||
|
echo "Recent Blocked Requests:"
|
||||||
|
tail -100 "$LOG_FILE" 2>/dev/null | \
|
||||||
|
grep -E "(bot_scanner|scan_admin|scan_shells|sql_injection|directory_traversal|rate_abuse|tarpit|denied|403)" | \
|
||||||
|
tail -10 | \
|
||||||
|
awk '{
|
||||||
|
if (match($0, /[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+:[0-9]+/)) {
|
||||||
|
ip = substr($0, RSTART, RLENGTH)
|
||||||
|
gsub(/:.*/, "", ip)
|
||||||
|
reason = ""
|
||||||
|
if ($0 ~ /bot_scanner/) reason = "BOT_SCANNER"
|
||||||
|
else if ($0 ~ /scan_admin/) reason = "ADMIN_SCAN"
|
||||||
|
else if ($0 ~ /scan_shells/) reason = "SHELL_SCAN"
|
||||||
|
else if ($0 ~ /sql_injection/) reason = "SQL_INJECTION"
|
||||||
|
else if ($0 ~ /directory_traversal/) reason = "DIR_TRAVERSAL"
|
||||||
|
else if ($0 ~ /rate_abuse/) reason = "RATE_ABUSE"
|
||||||
|
else if ($0 ~ /tarpit/) reason = "TARPIT"
|
||||||
|
else if ($0 ~ /denied/) reason = "DENIED"
|
||||||
|
else if ($0 ~ /403/) reason = "BLOCKED"
|
||||||
|
printf "[%s] %-15s %s\n", strftime("%H:%M:%S"), ip, reason
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Monitor mode selection
|
||||||
|
if [ "$1" == "live" ]; then
|
||||||
|
echo "Live monitoring mode - Press Ctrl+C to exit"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
clear
|
||||||
|
echo "==================================================="
|
||||||
|
echo "HAProxy Security Monitor - $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
echo "==================================================="
|
||||||
|
echo ""
|
||||||
|
show_threats
|
||||||
|
echo ""
|
||||||
|
show_recent_blocks
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Single run mode
|
||||||
|
show_threats
|
||||||
|
echo ""
|
||||||
|
show_recent_blocks
|
||||||
|
echo ""
|
||||||
|
echo "Tip: Run with 'live' parameter for continuous monitoring"
|
||||||
|
echo "Usage: $0 [live]"
|
||||||
|
fi
|
||||||
Executable
+224
@@ -0,0 +1,224 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HAProxy Manager External Monitoring Script
|
||||||
|
# This script monitors the HAProxy Manager from outside the container
|
||||||
|
|
||||||
|
# Configuration - modify these variables
|
||||||
|
CONTAINER_NAME="haproxy-manager"
|
||||||
|
CONTAINER_API_URL="http://localhost:8000"
|
||||||
|
LOG_DIR="/var/lib/docker/volumes/haproxy-logs/_data" # Adjust path as needed
|
||||||
|
ERROR_LOG="$LOG_DIR/haproxy-manager-errors.log"
|
||||||
|
ALERT_EMAIL=""
|
||||||
|
WEBHOOK_URL=""
|
||||||
|
API_KEY=""
|
||||||
|
|
||||||
|
# Load configuration from file if it exists
|
||||||
|
CONFIG_FILE="/etc/haproxy-monitor.conf"
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to send email alert
|
||||||
|
send_email_alert() {
|
||||||
|
local subject="$1"
|
||||||
|
local message="$2"
|
||||||
|
|
||||||
|
if [ -n "$ALERT_EMAIL" ]; then
|
||||||
|
if command -v mail >/dev/null 2>&1; then
|
||||||
|
echo "$message" | mail -s "$subject" "$ALERT_EMAIL"
|
||||||
|
else
|
||||||
|
echo "Email alert (mail command not available): $subject - $message"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to send webhook alert
|
||||||
|
send_webhook_alert() {
|
||||||
|
local message="$1"
|
||||||
|
|
||||||
|
if [ -n "$WEBHOOK_URL" ]; then
|
||||||
|
curl -s -X POST "$WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\":\"$message\"}" >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check if container is running
|
||||||
|
check_container_status() {
|
||||||
|
if ! docker ps --format "table {{.Names}}" | grep -q "^${CONTAINER_NAME}$"; then
|
||||||
|
local alert_message="HAProxy Manager Alert: Container $CONTAINER_NAME is not running!"
|
||||||
|
send_email_alert "HAProxy Manager Container Down" "$alert_message"
|
||||||
|
send_webhook_alert "$alert_message"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check for recent errors
|
||||||
|
check_recent_errors() {
|
||||||
|
local minutes="${1:-60}" # Default to last 60 minutes
|
||||||
|
|
||||||
|
if [ ! -f "$ERROR_LOG" ]; then
|
||||||
|
echo "Error log file not found: $ERROR_LOG"
|
||||||
|
echo "Container may not be running or log volume not mounted correctly"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current timestamp minus specified minutes
|
||||||
|
local cutoff_time=$(date -d "$minutes minutes ago" +%s)
|
||||||
|
|
||||||
|
# Check for errors in the last N minutes
|
||||||
|
local recent_errors=$(awk -v cutoff="$cutoff_time" '
|
||||||
|
BEGIN { FS="\""; found=0 }
|
||||||
|
/"timestamp":/ {
|
||||||
|
# Extract timestamp and convert to epoch
|
||||||
|
gsub(/[",]/, "", $4)
|
||||||
|
split($4, parts, "T")
|
||||||
|
split(parts[1], date_parts, "-")
|
||||||
|
split(parts[2], time_parts, ":")
|
||||||
|
timestamp = mktime(date_parts[1] " " date_parts[2] " " date_parts[3] " " time_parts[1] " " time_parts[2] " " time_parts[3])
|
||||||
|
if (timestamp > cutoff) {
|
||||||
|
found=1
|
||||||
|
print $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' "$ERROR_LOG")
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Recent errors found in the last $minutes minutes:"
|
||||||
|
echo "$recent_errors"
|
||||||
|
|
||||||
|
# Send alerts
|
||||||
|
local alert_message="HAProxy Manager Error Alert: Recent errors detected in the last $minutes minutes. Check $ERROR_LOG for details."
|
||||||
|
send_email_alert "HAProxy Manager Error Alert" "$alert_message"
|
||||||
|
send_webhook_alert "$alert_message"
|
||||||
|
|
||||||
|
return 1 # Return error status
|
||||||
|
else
|
||||||
|
echo "No recent errors found in the last $minutes minutes."
|
||||||
|
return 0 # Return success status
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check certificate expiration via API
|
||||||
|
check_certificate_expiration() {
|
||||||
|
local warning_days="${1:-30}" # Default to 30 days warning
|
||||||
|
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "No API key configured. Cannot check certificate status."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if container is running
|
||||||
|
if ! check_container_status; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use the API to get certificate status
|
||||||
|
local cert_status=$(curl -s -H "Authorization: Bearer $API_KEY" "$CONTAINER_API_URL/api/certificates/status")
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Parse JSON to check for expiring certificates
|
||||||
|
local expiring_certs=$(echo "$cert_status" | jq -r --arg days "$warning_days" '
|
||||||
|
.certificates[] |
|
||||||
|
select(.days_until_expiry != null and .days_until_expiry <= ($days | tonumber)) |
|
||||||
|
"\(.domain): expires in \(.days_until_expiry) days"
|
||||||
|
' 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -n "$expiring_certs" ]; then
|
||||||
|
echo "Certificates expiring soon:"
|
||||||
|
echo "$expiring_certs"
|
||||||
|
|
||||||
|
local alert_message="HAProxy Manager Certificate Alert: Certificates expiring soon. $expiring_certs"
|
||||||
|
send_email_alert "HAProxy Manager Certificate Alert" "$alert_message"
|
||||||
|
send_webhook_alert "$alert_message"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo "No certificates expiring within $warning_days days."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Failed to get certificate status from API."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check API health
|
||||||
|
check_api_health() {
|
||||||
|
local health_response=$(curl -s "$CONTAINER_API_URL/health")
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
local status=$(echo "$health_response" | jq -r '.status' 2>/dev/null)
|
||||||
|
if [ "$status" = "healthy" ]; then
|
||||||
|
echo "API health check passed"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "API health check failed: $health_response"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "API health check failed: cannot connect to $CONTAINER_API_URL"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script logic
|
||||||
|
case "${1:-help}" in
|
||||||
|
"container")
|
||||||
|
check_container_status
|
||||||
|
;;
|
||||||
|
"health")
|
||||||
|
check_api_health
|
||||||
|
;;
|
||||||
|
"errors")
|
||||||
|
check_recent_errors "${2:-60}"
|
||||||
|
;;
|
||||||
|
"certs")
|
||||||
|
check_certificate_expiration "${2:-30}"
|
||||||
|
;;
|
||||||
|
"all")
|
||||||
|
echo "Checking container status..."
|
||||||
|
check_container_status
|
||||||
|
container_status=$?
|
||||||
|
|
||||||
|
echo "Checking API health..."
|
||||||
|
check_api_health
|
||||||
|
health_status=$?
|
||||||
|
|
||||||
|
echo "Checking for recent errors..."
|
||||||
|
check_recent_errors "${2:-60}"
|
||||||
|
error_status=$?
|
||||||
|
|
||||||
|
echo "Checking certificate expiration..."
|
||||||
|
check_certificate_expiration "${3:-30}"
|
||||||
|
cert_status=$?
|
||||||
|
|
||||||
|
exit $((container_status + health_status + error_status + cert_status))
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "HAProxy Manager External Monitoring Script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 {container|health|errors|certs|all} [minutes] [cert_warning_days]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " container Check if container is running"
|
||||||
|
echo " health Check API health endpoint"
|
||||||
|
echo " errors [minutes] Check for errors in the last N minutes (default: 60)"
|
||||||
|
echo " certs [days] Check for certificates expiring within N days (default: 30)"
|
||||||
|
echo " all [minutes] [days] Check container, health, errors, and certificates"
|
||||||
|
echo " help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Configuration:"
|
||||||
|
echo " Set variables at the top of this script or create $CONFIG_FILE"
|
||||||
|
echo " Required variables: CONTAINER_NAME, CONTAINER_API_URL, API_KEY"
|
||||||
|
echo " Optional variables: ALERT_EMAIL, WEBHOOK_URL, LOG_DIR"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 container # Check if container is running"
|
||||||
|
echo " $0 errors 30 # Check for errors in last 30 minutes"
|
||||||
|
echo " $0 certs 7 # Check for certificates expiring in 7 days"
|
||||||
|
echo " $0 all 60 14 # Check everything (60 min errors, 14 day certs)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+159
@@ -0,0 +1,159 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HAProxy Manager Error Monitoring Script
|
||||||
|
# This script monitors the error log and can send alerts
|
||||||
|
|
||||||
|
ERROR_LOG="/var/log/haproxy-manager-errors.log"
|
||||||
|
ALERT_EMAIL=""
|
||||||
|
WEBHOOK_URL=""
|
||||||
|
|
||||||
|
# Function to send email alert
|
||||||
|
send_email_alert() {
|
||||||
|
local subject="$1"
|
||||||
|
local message="$2"
|
||||||
|
|
||||||
|
if [ -n "$ALERT_EMAIL" ]; then
|
||||||
|
echo "$message" | mail -s "$subject" "$ALERT_EMAIL"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to send webhook alert
|
||||||
|
send_webhook_alert() {
|
||||||
|
local message="$1"
|
||||||
|
|
||||||
|
if [ -n "$WEBHOOK_URL" ]; then
|
||||||
|
curl -X POST "$WEBHOOK_URL" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"text\":\"$message\"}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check for recent errors
|
||||||
|
check_recent_errors() {
|
||||||
|
local minutes="${1:-60}" # Default to last 60 minutes
|
||||||
|
|
||||||
|
if [ ! -f "$ERROR_LOG" ]; then
|
||||||
|
echo "Error log file not found: $ERROR_LOG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current timestamp minus specified minutes
|
||||||
|
local cutoff_time=$(date -d "$minutes minutes ago" +%s)
|
||||||
|
|
||||||
|
# Check for errors in the last N minutes
|
||||||
|
local recent_errors=$(awk -v cutoff="$cutoff_time" '
|
||||||
|
BEGIN { FS="\""; found=0 }
|
||||||
|
/"timestamp":/ {
|
||||||
|
# Extract timestamp and convert to epoch
|
||||||
|
gsub(/[",]/, "", $4)
|
||||||
|
split($4, parts, "T")
|
||||||
|
split(parts[1], date_parts, "-")
|
||||||
|
split(parts[2], time_parts, ":")
|
||||||
|
timestamp = mktime(date_parts[1] " " date_parts[2] " " date_parts[3] " " time_parts[1] " " time_parts[2] " " time_parts[3])
|
||||||
|
if (timestamp > cutoff) {
|
||||||
|
found=1
|
||||||
|
print $0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
END { exit found ? 0 : 1 }
|
||||||
|
' "$ERROR_LOG")
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Recent errors found in the last $minutes minutes:"
|
||||||
|
echo "$recent_errors"
|
||||||
|
|
||||||
|
# Send alerts
|
||||||
|
local alert_message="HAProxy Manager Error Alert: Recent errors detected in the last $minutes minutes. Check $ERROR_LOG for details."
|
||||||
|
send_email_alert "HAProxy Manager Error Alert" "$alert_message"
|
||||||
|
send_webhook_alert "$alert_message"
|
||||||
|
|
||||||
|
return 1 # Return error status
|
||||||
|
else
|
||||||
|
echo "No recent errors found in the last $minutes minutes."
|
||||||
|
return 0 # Return success status
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check certificate expiration
|
||||||
|
check_certificate_expiration() {
|
||||||
|
local warning_days="${1:-30}" # Default to 30 days warning
|
||||||
|
|
||||||
|
# Use the API to get certificate status
|
||||||
|
local api_key="${HAPROXY_API_KEY:-}"
|
||||||
|
local base_url="http://localhost:8000"
|
||||||
|
|
||||||
|
if [ -n "$api_key" ]; then
|
||||||
|
local cert_status=$(curl -s -H "Authorization: Bearer $api_key" "$base_url/api/certificates/status")
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
# Parse JSON to check for expiring certificates
|
||||||
|
local expiring_certs=$(echo "$cert_status" | jq -r --arg days "$warning_days" '
|
||||||
|
.certificates[] |
|
||||||
|
select(.days_until_expiry != null and .days_until_expiry <= ($days | tonumber)) |
|
||||||
|
"\(.domain): expires in \(.days_until_expiry) days"
|
||||||
|
')
|
||||||
|
|
||||||
|
if [ -n "$expiring_certs" ]; then
|
||||||
|
echo "Certificates expiring soon:"
|
||||||
|
echo "$expiring_certs"
|
||||||
|
|
||||||
|
local alert_message="HAProxy Manager Certificate Alert: Certificates expiring soon. $expiring_certs"
|
||||||
|
send_email_alert "HAProxy Manager Certificate Alert" "$alert_message"
|
||||||
|
send_webhook_alert "$alert_message"
|
||||||
|
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
echo "No certificates expiring within $warning_days days."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Failed to get certificate status from API."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "No API key configured. Cannot check certificate status."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script logic
|
||||||
|
case "${1:-help}" in
|
||||||
|
"errors")
|
||||||
|
check_recent_errors "${2:-60}"
|
||||||
|
;;
|
||||||
|
"certs")
|
||||||
|
check_certificate_expiration "${2:-30}"
|
||||||
|
;;
|
||||||
|
"all")
|
||||||
|
echo "Checking for recent errors..."
|
||||||
|
check_recent_errors "${2:-60}"
|
||||||
|
error_status=$?
|
||||||
|
|
||||||
|
echo "Checking certificate expiration..."
|
||||||
|
check_certificate_expiration "${3:-30}"
|
||||||
|
cert_status=$?
|
||||||
|
|
||||||
|
exit $((error_status + cert_status))
|
||||||
|
;;
|
||||||
|
"help"|*)
|
||||||
|
echo "HAProxy Manager Monitoring Script"
|
||||||
|
echo ""
|
||||||
|
echo "Usage: $0 {errors|certs|all} [minutes] [cert_warning_days]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " errors [minutes] Check for errors in the last N minutes (default: 60)"
|
||||||
|
echo " certs [days] Check for certificates expiring within N days (default: 30)"
|
||||||
|
echo " all [minutes] [days] Check both errors and certificates"
|
||||||
|
echo " help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " ALERT_EMAIL Email address for alerts"
|
||||||
|
echo " WEBHOOK_URL Webhook URL for alerts"
|
||||||
|
echo " HAPROXY_API_KEY API key for certificate status checks"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 errors 30 # Check for errors in last 30 minutes"
|
||||||
|
echo " $0 certs 7 # Check for certificates expiring in 7 days"
|
||||||
|
echo " $0 all 60 14 # Check both (60 min errors, 14 day certs)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# HAProxy Manager Monitoring Configuration Example
|
||||||
|
# Copy this file and modify it for your environment
|
||||||
|
|
||||||
|
# Email alerts (requires mailutils to be installed)
|
||||||
|
ALERT_EMAIL="admin@yourdomain.com"
|
||||||
|
|
||||||
|
# Webhook alerts (e.g., Slack, Discord, etc.)
|
||||||
|
WEBHOOK_URL="https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK"
|
||||||
|
|
||||||
|
# API key for certificate status checks
|
||||||
|
HAPROXY_API_KEY="your-secure-api-key-here"
|
||||||
|
|
||||||
|
# Monitoring intervals (in minutes)
|
||||||
|
ERROR_CHECK_INTERVAL=30
|
||||||
|
CERT_CHECK_INTERVAL=1440 # 24 hours
|
||||||
|
|
||||||
|
# Certificate warning threshold (days before expiration)
|
||||||
|
CERT_WARNING_DAYS=30
|
||||||
|
|
||||||
|
# Example crontab entries for monitoring:
|
||||||
|
# Check for errors every 30 minutes
|
||||||
|
# */30 * * * * /haproxy/scripts/monitor-errors.sh errors 30
|
||||||
|
|
||||||
|
# Check certificates daily
|
||||||
|
# 0 9 * * * /haproxy/scripts/monitor-errors.sh certs 30
|
||||||
|
|
||||||
|
# Check both errors and certificates daily
|
||||||
|
# 0 9 * * * /haproxy/scripts/monitor-errors.sh all 60 30
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Certificate Renewal Script for HAProxy Manager
|
||||||
|
# This script runs certbot renew and copies certificates to HAProxy format
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
LOG_FILE="${LOG_FILE:-/var/log/haproxy-manager.log}"
|
||||||
|
ERROR_LOG_FILE="${ERROR_LOG_FILE:-/var/log/haproxy-manager-errors.log}"
|
||||||
|
DB_FILE="${DB_FILE:-/etc/haproxy/haproxy_config.db}"
|
||||||
|
SSL_CERTS_DIR="${SSL_CERTS_DIR:-/etc/haproxy/certs}"
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE" >> "$ERROR_LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info "Starting certificate renewal process"
|
||||||
|
|
||||||
|
# Run certbot renewal — don't exit on failure, some certs may have
|
||||||
|
# renewed successfully even if others failed (e.g., domain no longer
|
||||||
|
# pointed here). Continue to copy/combine whatever succeeded.
|
||||||
|
CERTBOT_OUTPUT=$(certbot renew --no-random-sleep-on-renew 2>&1)
|
||||||
|
CERTBOT_EXIT=$?
|
||||||
|
|
||||||
|
if [ $CERTBOT_EXIT -eq 0 ]; then
|
||||||
|
log_info "Certbot renewal completed successfully"
|
||||||
|
else
|
||||||
|
log_error "Certbot renewal had failures (exit code $CERTBOT_EXIT):"
|
||||||
|
# Log the specific failures
|
||||||
|
echo "$CERTBOT_OUTPUT" | grep -E "Failed to renew|failure" | while read -r line; do
|
||||||
|
log_error " $line"
|
||||||
|
done
|
||||||
|
log_info "Continuing to process successfully renewed certificates..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy all certificates to HAProxy format
|
||||||
|
# Ensure SSL certs directory exists
|
||||||
|
mkdir -p "$SSL_CERTS_DIR"
|
||||||
|
|
||||||
|
# Get all SSL-enabled domains from database
|
||||||
|
DOMAINS=$(find /etc/letsencrypt/live/ -mindepth 1 -maxdepth 1 -type d -printf '%f\n')
|
||||||
|
|
||||||
|
if [ -z "$DOMAINS" ]; then
|
||||||
|
log_info "No SSL-enabled domains found"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy certificates for each domain
|
||||||
|
UPDATED=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
while read -r domain; do
|
||||||
|
CERT_FILE="/etc/letsencrypt/live/${domain}/fullchain.pem"
|
||||||
|
KEY_FILE="/etc/letsencrypt/live/${domain}/privkey.pem"
|
||||||
|
COMBINED_FILE="${SSL_CERTS_DIR}/${domain}.pem"
|
||||||
|
|
||||||
|
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
|
||||||
|
# Combine cert and key into single file for HAProxy
|
||||||
|
if cat "$CERT_FILE" "$KEY_FILE" > "$COMBINED_FILE"; then
|
||||||
|
log_info "Updated certificate for $domain"
|
||||||
|
UPDATED=$((UPDATED + 1))
|
||||||
|
else
|
||||||
|
log_error "Failed to combine certificate for $domain"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "Certificate files not found for $domain"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$DOMAINS"
|
||||||
|
|
||||||
|
log_info "Certificate update completed: $UPDATED updated, $FAILED failed"
|
||||||
|
|
||||||
|
# Reload HAProxy if any certificates were updated
|
||||||
|
if [ $UPDATED -gt 0 ]; then
|
||||||
|
if echo "reload" | socat stdio /tmp/haproxy-cli 2>/dev/null; then
|
||||||
|
log_info "HAProxy reloaded successfully"
|
||||||
|
else
|
||||||
|
log_error "Failed to reload HAProxy"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Certificate renewal process completed"
|
||||||
|
exit 0
|
||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script to display IPs that have been tarpitted by HAProxy 3.0
|
||||||
|
# Uses HAProxy stats socket to query stick-table data
|
||||||
|
#
|
||||||
|
# Usage in Docker container:
|
||||||
|
# docker exec -it haproxy-manager /haproxy/scripts/show-tarpit-ips.sh
|
||||||
|
|
||||||
|
SOCKET="/tmp/haproxy-cli"
|
||||||
|
|
||||||
|
# Check if socket exists
|
||||||
|
if [ ! -S "$SOCKET" ]; then
|
||||||
|
echo "Error: HAProxy socket not found at $SOCKET"
|
||||||
|
echo "Make sure HAProxy is running with stats socket enabled"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==================================================================="
|
||||||
|
echo " HAProxy Tarpitted IPs Report "
|
||||||
|
echo "==================================================================="
|
||||||
|
echo
|
||||||
|
echo "Showing IPs tracked in the stick-table with scan detection counters:"
|
||||||
|
echo "(gpc0 = total scan attempts, gpc1 = escalation level)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# In HAProxy 3.0, we need to use the proper process prefix
|
||||||
|
# The web frontend table is in the worker process, not master
|
||||||
|
# First check which process has the table
|
||||||
|
# Note: grep for actual worker line, not the header
|
||||||
|
PROCESS_ID=$(echo "show proc" | socat stdio "$SOCKET" 2>/dev/null | grep -E '^[0-9]+.*worker' | awk '{print $1}' | head -1)
|
||||||
|
|
||||||
|
if [ -z "$PROCESS_ID" ]; then
|
||||||
|
echo "Error: Could not find HAProxy worker process"
|
||||||
|
echo "Try: echo 'show proc' | socat stdio $SOCKET"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show stick-table entries from the web frontend using the worker process
|
||||||
|
# Use printf to avoid bash history expansion issues with !
|
||||||
|
printf "@!%s show table web\n" "${PROCESS_ID}" | socat stdio "$SOCKET" 2>/dev/null | {
|
||||||
|
# Skip the header line
|
||||||
|
read header
|
||||||
|
|
||||||
|
# Check if we got an error or empty response
|
||||||
|
if echo "$header" | grep -q "No such table"; then
|
||||||
|
echo "Error: Table 'web' not found. HAProxy may need to be reloaded."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
has_data=false
|
||||||
|
echo "IP Address | Scan Count | Level | HTTP Err Rate | Status"
|
||||||
|
echo "---------------------|------------|-------|---------------|------------------"
|
||||||
|
|
||||||
|
# Process each line
|
||||||
|
while IFS= read -r line; do
|
||||||
|
# Skip empty lines and comments
|
||||||
|
if [ -z "$line" ] || echo "$line" | grep -q "^#"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HAProxy 3.0 format: 0x... key=<ip> use=... exp=... gpc0=... gpc1=... http_err_rate(10s)=...
|
||||||
|
if echo "$line" | grep -q "key="; then
|
||||||
|
has_data=true
|
||||||
|
|
||||||
|
# Extract IP and counters
|
||||||
|
ip=$(echo "$line" | grep -o 'key=[^ ]*' | cut -d'=' -f2)
|
||||||
|
gpc0=$(echo "$line" | grep -o 'gpc0=[0-9]*' | cut -d'=' -f2)
|
||||||
|
gpc1=$(echo "$line" | grep -o 'gpc1=[0-9]*' | cut -d'=' -f2)
|
||||||
|
err_rate=$(echo "$line" | grep -o 'http_err_rate([^)]*=[0-9]*' | grep -o '[0-9]*$')
|
||||||
|
|
||||||
|
# Set defaults if values are empty
|
||||||
|
gpc0=${gpc0:-0}
|
||||||
|
gpc1=${gpc1:-0}
|
||||||
|
err_rate=${err_rate:-0}
|
||||||
|
|
||||||
|
# Determine status based on scan count and escalation
|
||||||
|
status=""
|
||||||
|
if [ "$gpc0" -ge 100 ]; then
|
||||||
|
status="BLOCKED (429)"
|
||||||
|
elif [ "$gpc0" -ge 60 ]; then
|
||||||
|
status="SILENT-DROP"
|
||||||
|
elif [ "$gpc0" -ge 40 ]; then
|
||||||
|
if [ "$gpc1" -ge 2 ]; then
|
||||||
|
status="SILENT-DROP (repeat)"
|
||||||
|
else
|
||||||
|
status="TARPIT 10s"
|
||||||
|
fi
|
||||||
|
elif [ "$gpc0" -ge 25 ]; then
|
||||||
|
status="TARPIT 10s"
|
||||||
|
else
|
||||||
|
status="Normal"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
printf "%-20s | %10s | %5s | %13s | %s\n" "$ip" "$gpc0" "$gpc1" "$err_rate/10s" "$status"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$has_data" = false ]; then
|
||||||
|
echo "(No IPs currently tracked - table is empty)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==================================================================="
|
||||||
|
echo "Legend:"
|
||||||
|
echo " - Scan Count 25-39: Low scanner → TARPIT 10s delay"
|
||||||
|
echo " - Scan Count 40-59: Medium scanner → TARPIT 10s (1st), SILENT-DROP (repeat)"
|
||||||
|
echo " - Scan Count 60-99: High scanner → SILENT-DROP (immediate disconnect)"
|
||||||
|
echo " - Scan Count 100+: Critical scanner → BLOCKED (429 response)"
|
||||||
|
echo " - Burst (5+ in 10s): → TARPIT 10s (1st), SILENT-DROP (repeat)"
|
||||||
|
echo "==================================================================="
|
||||||
|
echo "Note: Only counts suspicious scripts/configs, NOT missing images/fonts/CSS"
|
||||||
|
echo "Note: IPs are tracked for 1 hour since last activity"
|
||||||
|
echo
|
||||||
|
echo "To clear a specific IP from the table:"
|
||||||
|
echo " printf '@!${PROCESS_ID} del table web key <IP>\\n' | socat stdio $SOCKET"
|
||||||
|
echo
|
||||||
|
echo "To clear all entries:"
|
||||||
|
echo " printf '@!${PROCESS_ID} clear table web\\n' | socat stdio $SOCKET"
|
||||||
|
echo
|
||||||
|
echo "Debug: Worker PID is ${PROCESS_ID}"
|
||||||
|
echo
|
||||||
Regular → Executable
+79
-2
@@ -1,6 +1,83 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Container entrypoint. Two-phase startup:
|
||||||
|
# 1. One-shot init (init.py): DB schema, certbot register, config gen, start HAProxy.
|
||||||
|
# Runs synchronously and to completion so haproxy is up before the API binds.
|
||||||
|
# 2. WSGI serving via gunicorn (replacing the Flask dev server). Two gunicorn
|
||||||
|
# instances:
|
||||||
|
# - port 8080 -> default_app (default page + blocked-ip page; HAProxy
|
||||||
|
# proxies unmatched / blocked traffic here)
|
||||||
|
# - port 8000 -> app (management API)
|
||||||
|
#
|
||||||
|
# Why gunicorn:
|
||||||
|
# Flask's built-in werkzeug "development server" is single-threaded and leaks
|
||||||
|
# workers under sustained load. It carried haproxy-manager for a long time but
|
||||||
|
# stalled out around 24-48h uptime ("healthy" health-check, but every request
|
||||||
|
# queued behind a stuck worker). Gunicorn with --max-requests cycles workers
|
||||||
|
# periodically, which prevents the slow-leak failure mode entirely.
|
||||||
|
|
||||||
# Exit on error
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
|
# Ensure trusted IP whitelist files exist (volume-mounted /etc/haproxy may shadow image defaults)
|
||||||
|
mkdir -p /etc/haproxy
|
||||||
|
[ -f /etc/haproxy/trusted_ips.list ] || : > /etc/haproxy/trusted_ips.list
|
||||||
|
[ -f /etc/haproxy/trusted_ips.map ] || : > /etc/haproxy/trusted_ips.map
|
||||||
|
|
||||||
cron &
|
cron &
|
||||||
python /haproxy/haproxy_manager.py
|
|
||||||
|
# Phase 1: container init
|
||||||
|
python /haproxy/scripts/init.py
|
||||||
|
|
||||||
|
# Phase 1.5: in-container haproxy supervisor.
|
||||||
|
# haproxy runs as a background child of PID 1 (gunicorn) with NOTHING watching
|
||||||
|
# it after init. If the haproxy master dies mid-life (e.g. SIGABRT -> exit 134,
|
||||||
|
# segfault), the container stays "up" (gunicorn is PID 1), Docker's --restart
|
||||||
|
# policy never fires, and haproxy is down until the external host watchdog
|
||||||
|
# full-restarts the whole container minutes later (dropping every connection).
|
||||||
|
# This loop revives haproxy in place within one interval. ensure_haproxy.py is
|
||||||
|
# idempotent — a cheap no-op whenever haproxy is already running.
|
||||||
|
HAPROXY_SUPERVISOR_INTERVAL="${HAPROXY_SUPERVISOR_INTERVAL:-15}"
|
||||||
|
(
|
||||||
|
while true; do
|
||||||
|
sleep "${HAPROXY_SUPERVISOR_INTERVAL}"
|
||||||
|
python /haproxy/scripts/ensure_haproxy.py 2>&1 || true
|
||||||
|
done
|
||||||
|
) &
|
||||||
|
|
||||||
|
# Phase 2: WSGI servers
|
||||||
|
# Tunable via env: HAPROXY_MGR_API_WORKERS (default 1), HAPROXY_MGR_API_TIMEOUT
|
||||||
|
# (default 120 — API can do slow ACME calls), HAPROXY_MGR_MAX_REQUESTS (default
|
||||||
|
# 1000 — worker recycle frequency).
|
||||||
|
#
|
||||||
|
# API_WORKERS default is 2 (was 1). A single worker is a single point of
|
||||||
|
# failure: if its gthread pool ever wedges (see the 2026-07-07 subprocess-hang
|
||||||
|
# incident — now bounded by DEFAULT_SUBPROCESS_TIMEOUT in haproxy_manager.py),
|
||||||
|
# the entire management API goes dark. A second worker keeps the API answering
|
||||||
|
# (config regenerate, health, SSL) while the other recycles via --max-requests.
|
||||||
|
API_WORKERS="${HAPROXY_MGR_API_WORKERS:-2}"
|
||||||
|
API_TIMEOUT="${HAPROXY_MGR_API_TIMEOUT:-120}"
|
||||||
|
MAX_REQ="${HAPROXY_MGR_MAX_REQUESTS:-1000}"
|
||||||
|
MAX_REQ_JITTER="${HAPROXY_MGR_MAX_REQUESTS_JITTER:-100}"
|
||||||
|
|
||||||
|
# Default page server on :8080. Stays in the background.
|
||||||
|
# --threads 4 lets one worker handle bursts of blocked-IP/default-page hits
|
||||||
|
# without forking. --max-requests recycles the worker to bound memory drift.
|
||||||
|
gunicorn \
|
||||||
|
--bind 0.0.0.0:8080 \
|
||||||
|
--workers 1 --threads 4 --worker-class gthread \
|
||||||
|
--max-requests "${MAX_REQ}" --max-requests-jitter "${MAX_REQ_JITTER}" \
|
||||||
|
--timeout 30 \
|
||||||
|
--access-logfile - --error-logfile - --log-level info \
|
||||||
|
--pythonpath /haproxy \
|
||||||
|
'haproxy_manager:default_app' &
|
||||||
|
|
||||||
|
# Main API server on :8000 in the foreground. exec so signals propagate
|
||||||
|
# correctly and the container exits if the API dies (docker --restart picks it
|
||||||
|
# up). Longer --timeout because cert issuance hits ACME and can take a while.
|
||||||
|
exec gunicorn \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers "${API_WORKERS}" --threads 4 --worker-class gthread \
|
||||||
|
--max-requests "${MAX_REQ}" --max-requests-jitter "${MAX_REQ_JITTER}" \
|
||||||
|
--timeout "${API_TIMEOUT}" \
|
||||||
|
--access-logfile - --error-logfile - --log-level info \
|
||||||
|
--pythonpath /haproxy \
|
||||||
|
'haproxy_manager:app'
|
||||||
|
|||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Certificate Sync Script for HAProxy Manager
|
||||||
|
# This script syncs all Let's Encrypt certificates to HAProxy format without running certbot renew
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
LOG_FILE="${LOG_FILE:-/var/log/haproxy-manager.log}"
|
||||||
|
ERROR_LOG_FILE="${ERROR_LOG_FILE:-/var/log/haproxy-manager-errors.log}"
|
||||||
|
DB_FILE="${DB_FILE:-/etc/haproxy/haproxy_config.db}"
|
||||||
|
SSL_CERTS_DIR="${SSL_CERTS_DIR:-/etc/haproxy/certs}"
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [INFO] $*" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" | tee -a "$LOG_FILE" >> "$ERROR_LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_info "Starting certificate sync process"
|
||||||
|
|
||||||
|
# Ensure SSL certs directory exists
|
||||||
|
mkdir -p "$SSL_CERTS_DIR"
|
||||||
|
|
||||||
|
# Get all SSL-enabled domains from database
|
||||||
|
DOMAINS=$(find /etc/letsencrypt/live/ -mindepth 1 -maxdepth 1 -type d -printf '%f\n')
|
||||||
|
|
||||||
|
if [ -z "$DOMAINS" ]; then
|
||||||
|
log_info "No SSL-enabled domains found"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy certificates for each domain
|
||||||
|
UPDATED=0
|
||||||
|
FAILED=0
|
||||||
|
|
||||||
|
while read -r domain; do
|
||||||
|
CERT_FILE="/etc/letsencrypt/live/${domain}/fullchain.pem"
|
||||||
|
KEY_FILE="/etc/letsencrypt/live/${domain}/privkey.pem"
|
||||||
|
COMBINED_FILE="${SSL_CERTS_DIR}/${domain}.pem"
|
||||||
|
|
||||||
|
if [ -f "$CERT_FILE" ] && [ -f "$KEY_FILE" ]; then
|
||||||
|
# Combine cert and key into single file for HAProxy
|
||||||
|
if cat "$CERT_FILE" "$KEY_FILE" > "$COMBINED_FILE"; then
|
||||||
|
log_info "Updated certificate for $domain"
|
||||||
|
UPDATED=$((UPDATED + 1))
|
||||||
|
else
|
||||||
|
log_error "Failed to combine certificate for $domain"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "Certificate files not found for $domain"
|
||||||
|
FAILED=$((FAILED + 1))
|
||||||
|
fi
|
||||||
|
done <<< "$DOMAINS"
|
||||||
|
|
||||||
|
log_info "Certificate sync completed: $UPDATED updated, $FAILED failed"
|
||||||
|
|
||||||
|
# Reload HAProxy if any certificates were updated
|
||||||
|
if [ $UPDATED -gt 0 ]; then
|
||||||
|
if echo "reload" | socat stdio /tmp/haproxy-cli 2>/dev/null; then
|
||||||
|
log_info "HAProxy reloaded successfully"
|
||||||
|
else
|
||||||
|
log_error "Failed to reload HAProxy"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Certificate sync process completed"
|
||||||
|
exit 0
|
||||||
Executable
+161
@@ -0,0 +1,161 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HAProxy Manager API Test Script
|
||||||
|
# This script tests the new API endpoints
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8000"
|
||||||
|
API_KEY="${HAPROXY_API_KEY:-}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_status() {
|
||||||
|
local status=$1
|
||||||
|
local message=$2
|
||||||
|
|
||||||
|
if [ "$status" = "PASS" ]; then
|
||||||
|
echo -e "${GREEN}✓ PASS${NC}: $message"
|
||||||
|
elif [ "$status" = "FAIL" ]; then
|
||||||
|
echo -e "${RED}✗ FAIL${NC}: $message"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}? INFO${NC}: $message"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to make API request
|
||||||
|
api_request() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
|
||||||
|
local headers=""
|
||||||
|
if [ -n "$API_KEY" ]; then
|
||||||
|
headers="-H \"Authorization: Bearer $API_KEY\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$data" ]; then
|
||||||
|
headers="$headers -H \"Content-Type: application/json\" -d '$data'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "curl -s -w \"%{http_code}\" -o /tmp/api_response.json $headers -X $method $BASE_URL$endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test health endpoint (no auth required)
|
||||||
|
test_health() {
|
||||||
|
print_status "INFO" "Testing health endpoint..."
|
||||||
|
local response=$(api_request "GET" "/health")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ]; then
|
||||||
|
print_status "PASS" "Health endpoint working"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Health endpoint failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test domains endpoint
|
||||||
|
test_domains() {
|
||||||
|
print_status "INFO" "Testing domains endpoint..."
|
||||||
|
local response=$(api_request "GET" "/api/domains")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Domains endpoint responded correctly (status: $status_code)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Domains endpoint failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test certificate status endpoint
|
||||||
|
test_cert_status() {
|
||||||
|
print_status "INFO" "Testing certificate status endpoint..."
|
||||||
|
local response=$(api_request "GET" "/api/certificates/status")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Certificate status endpoint responded correctly (status: $status_code)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Certificate status endpoint failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test certificate renewal endpoint
|
||||||
|
test_cert_renewal() {
|
||||||
|
print_status "INFO" "Testing certificate renewal endpoint..."
|
||||||
|
local response=$(api_request "POST" "/api/certificates/renew")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Certificate renewal endpoint responded correctly (status: $status_code)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Certificate renewal endpoint failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test reload endpoint
|
||||||
|
test_reload() {
|
||||||
|
print_status "INFO" "Testing HAProxy reload endpoint..."
|
||||||
|
local response=$(api_request "GET" "/api/reload")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Reload endpoint responded correctly (status: $status_code)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Reload endpoint failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test authentication
|
||||||
|
test_auth() {
|
||||||
|
if [ -n "$API_KEY" ]; then
|
||||||
|
print_status "INFO" "API key is configured"
|
||||||
|
|
||||||
|
# Test with valid API key
|
||||||
|
local response=$(api_request "GET" "/api/domains")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ]; then
|
||||||
|
print_status "PASS" "Authentication working with API key"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Authentication failed with API key (status: $status_code)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "INFO" "No API key configured - testing without authentication"
|
||||||
|
|
||||||
|
# Test without API key
|
||||||
|
local response=$(api_request "GET" "/api/domains")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ]; then
|
||||||
|
print_status "PASS" "API accessible without authentication"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "API not accessible without authentication (status: $status_code)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main test execution
|
||||||
|
main() {
|
||||||
|
echo "HAProxy Manager API Test Suite"
|
||||||
|
echo "=============================="
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo "API Key: ${API_KEY:-"Not configured"}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
test_health
|
||||||
|
test_auth
|
||||||
|
test_domains
|
||||||
|
test_cert_status
|
||||||
|
test_cert_renewal
|
||||||
|
test_reload
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Test completed. Check /tmp/api_response.json for detailed responses."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
main "$@"
|
||||||
Executable
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HAProxy Manager Certificate Request Test Script
|
||||||
|
# This script tests the new certificate request endpoint
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8000"
|
||||||
|
API_KEY="${HAPROXY_API_KEY:-}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_status() {
|
||||||
|
local status=$1
|
||||||
|
local message=$2
|
||||||
|
|
||||||
|
case $status in
|
||||||
|
"PASS")
|
||||||
|
echo -e "${GREEN}✓ PASS${NC}: $message"
|
||||||
|
;;
|
||||||
|
"FAIL")
|
||||||
|
echo -e "${RED}✗ FAIL${NC}: $message"
|
||||||
|
;;
|
||||||
|
"INFO")
|
||||||
|
echo -e "${BLUE}ℹ INFO${NC}: $message"
|
||||||
|
;;
|
||||||
|
"WARN")
|
||||||
|
echo -e "${YELLOW}⚠ WARN${NC}: $message"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to make API request
|
||||||
|
api_request() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
|
||||||
|
local headers=""
|
||||||
|
if [ -n "$API_KEY" ]; then
|
||||||
|
headers="-H \"Authorization: Bearer $API_KEY\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$data" ]; then
|
||||||
|
headers="$headers -H \"Content-Type: application/json\" -d '$data'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "curl -s -w \"%{http_code}\" -o /tmp/cert_request_response.json $headers -X $method $BASE_URL$endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test single domain certificate request
|
||||||
|
test_single_domain_request() {
|
||||||
|
print_status "INFO" "Testing single domain certificate request..."
|
||||||
|
|
||||||
|
local test_domain="test-$(date +%s).example.com"
|
||||||
|
local data="{\"domains\": [\"$test_domain\"], \"force_renewal\": false, \"include_www\": false}"
|
||||||
|
|
||||||
|
local response=$(api_request "POST" "/api/certificates/request" "$data")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "207" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Single domain request endpoint responded (status: $status_code)"
|
||||||
|
|
||||||
|
if [ "$status_code" != "401" ]; then
|
||||||
|
# Parse response
|
||||||
|
local success_count=$(jq -r '.summary.successful' /tmp/cert_request_response.json 2>/dev/null)
|
||||||
|
local failed_count=$(jq -r '.summary.failed' /tmp/cert_request_response.json 2>/dev/null)
|
||||||
|
|
||||||
|
if [ "$success_count" = "1" ]; then
|
||||||
|
print_status "PASS" "Certificate request successful for $test_domain"
|
||||||
|
elif [ "$failed_count" = "1" ]; then
|
||||||
|
print_status "WARN" "Certificate request failed for $test_domain (expected for test domain)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Unexpected response format"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Single domain request failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test multiple domain certificate request
|
||||||
|
test_multiple_domain_request() {
|
||||||
|
print_status "INFO" "Testing multiple domain certificate request..."
|
||||||
|
|
||||||
|
local test_domains="[\"test1-$(date +%s).example.com\", \"test2-$(date +%s).example.com\"]"
|
||||||
|
local data="{\"domains\": $test_domains, \"force_renewal\": false, \"include_www\": true}"
|
||||||
|
|
||||||
|
local response=$(api_request "POST" "/api/certificates/request" "$data")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "207" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Multiple domain request endpoint responded (status: $status_code)"
|
||||||
|
|
||||||
|
if [ "$status_code" != "401" ]; then
|
||||||
|
local total=$(jq -r '.summary.total' /tmp/cert_request_response.json 2>/dev/null)
|
||||||
|
if [ "$total" = "2" ]; then
|
||||||
|
print_status "PASS" "Multiple domain request processed correctly"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Multiple domain request response format error"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Multiple domain request failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test certificate request with force renewal
|
||||||
|
test_force_renewal_request() {
|
||||||
|
print_status "INFO" "Testing certificate request with force renewal..."
|
||||||
|
|
||||||
|
local test_domain="test-force-$(date +%s).example.com"
|
||||||
|
local data="{\"domains\": [\"$test_domain\"], \"force_renewal\": true, \"include_www\": false}"
|
||||||
|
|
||||||
|
local response=$(api_request "POST" "/api/certificates/request" "$data")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "207" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Force renewal request endpoint responded (status: $status_code)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Force renewal request failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test invalid request (no domains)
|
||||||
|
test_invalid_request() {
|
||||||
|
print_status "INFO" "Testing invalid request (no domains)..."
|
||||||
|
|
||||||
|
local data="{\"domains\": [], \"force_renewal\": false, \"include_www\": false}"
|
||||||
|
|
||||||
|
local response=$(api_request "POST" "/api/certificates/request" "$data")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "400" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Invalid request properly rejected (status: $status_code)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Invalid request not properly rejected (status: $status_code)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test certificate status endpoint
|
||||||
|
test_certificate_status() {
|
||||||
|
print_status "INFO" "Testing certificate status endpoint..."
|
||||||
|
|
||||||
|
local response=$(api_request "GET" "/api/certificates/status")
|
||||||
|
local status_code=$(echo "$response" | tail -c 4)
|
||||||
|
|
||||||
|
if [ "$status_code" = "200" ] || [ "$status_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Certificate status endpoint responded (status: $status_code)"
|
||||||
|
|
||||||
|
if [ "$status_code" != "401" ]; then
|
||||||
|
local cert_count=$(jq -r '.certificates | length' /tmp/cert_request_response.json 2>/dev/null)
|
||||||
|
print_status "INFO" "Found $cert_count certificates in status"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Certificate status failed with status $status_code"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main test execution
|
||||||
|
main() {
|
||||||
|
echo "HAProxy Manager Certificate Request Test Suite"
|
||||||
|
echo "=============================================="
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo "API Key: ${API_KEY:-"Not configured"}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
test_invalid_request
|
||||||
|
test_single_domain_request
|
||||||
|
test_multiple_domain_request
|
||||||
|
test_force_renewal_request
|
||||||
|
test_certificate_status
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Test completed. Check /tmp/cert_request_response.json for detailed responses."
|
||||||
|
echo ""
|
||||||
|
echo "Note: Certificate requests for test domains will likely fail as they don't"
|
||||||
|
echo "resolve to this server. This is expected behavior for testing."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
main "$@"
|
||||||
Executable
+467
@@ -0,0 +1,467 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for HAProxy config backup / rollback ordering.
|
||||||
|
|
||||||
|
Why this file exists
|
||||||
|
--------------------
|
||||||
|
generate_config() used to write the new haproxy.cfg and only THEN call
|
||||||
|
reload_haproxy_safely() -> create_backup(), so the "backup" was a copy of the
|
||||||
|
config that had just been written. On a validation failure restore_backup()
|
||||||
|
restored the identical broken bytes: the advertised rollback was a no-op and a
|
||||||
|
fatal haproxy.cfg stayed on disk, where start_haproxy() refuses to launch.
|
||||||
|
|
||||||
|
These tests pin the ordering invariant (backup predates the write) and the
|
||||||
|
observable end-to-end behaviour (after a failed validation the file on disk is
|
||||||
|
the previous working config and HAProxy will start with it).
|
||||||
|
|
||||||
|
Running
|
||||||
|
-------
|
||||||
|
python3 scripts/test-config-rollback.py # tests the repo checkout
|
||||||
|
HAPROXY_MANAGER_DIR=/some/other/tree \
|
||||||
|
python3 scripts/test-config-rollback.py # tests another tree
|
||||||
|
|
||||||
|
The repo has no Python test framework (scripts/test-*.sh are curl-based
|
||||||
|
integration scripts against a running API), so this is a self-contained
|
||||||
|
stdlib-unittest script - no pytest, no venv, no new dependencies beyond the
|
||||||
|
application's own requirements.txt (Flask/Jinja2/psutil), which are already
|
||||||
|
present in the container image.
|
||||||
|
|
||||||
|
No HAProxy binary is required: a stub `haproxy` is put on PATH that mimics
|
||||||
|
`haproxy -c -f <file>` by rejecting any config containing the token
|
||||||
|
__BROKEN__, which is how the tests inject an invalid configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
BROKEN_TOKEN = '__BROKEN__'
|
||||||
|
|
||||||
|
MODULE_DIR = os.path.abspath(
|
||||||
|
os.environ.get('HAPROXY_MANAGER_DIR',
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# haproxy_manager builds its Jinja2 environment from the relative path
|
||||||
|
# Path('templates'), so it has to be imported with the module dir as cwd.
|
||||||
|
os.chdir(MODULE_DIR)
|
||||||
|
sys.path.insert(0, MODULE_DIR)
|
||||||
|
|
||||||
|
# The module opens /var/log/haproxy-manager.log at import time via
|
||||||
|
# logging.FileHandler. Redirect that one call so the suite runs unprivileged.
|
||||||
|
_LOG_DIR = tempfile.mkdtemp(prefix='haproxy-mgr-test-logs-')
|
||||||
|
_real_file_handler = logging.FileHandler
|
||||||
|
logging.FileHandler = (
|
||||||
|
lambda fn, *a, **kw: _real_file_handler(
|
||||||
|
os.path.join(_LOG_DIR, os.path.basename(fn)), *a, **kw)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
import haproxy_manager as hm
|
||||||
|
except ImportError as exc: # pragma: no cover - environment problem, not a failure
|
||||||
|
sys.stderr.write(
|
||||||
|
f"SKIP: cannot import haproxy_manager ({exc}).\n"
|
||||||
|
"Install the application requirements first: pip install -r requirements.txt\n"
|
||||||
|
)
|
||||||
|
raise SystemExit(77)
|
||||||
|
finally:
|
||||||
|
logging.FileHandler = _real_file_handler
|
||||||
|
|
||||||
|
logging.getLogger('haproxy_manager').setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
FAKE_HAPROXY = textwrap.dedent(f"""\
|
||||||
|
#!/bin/sh
|
||||||
|
# Test stub for the haproxy binary.
|
||||||
|
# haproxy -c -f FILE -> exit 1 if FILE contains {BROKEN_TOKEN}, else 0
|
||||||
|
# haproxy -W -S ... -f FILE (start) -> same validation, then exit 0
|
||||||
|
cfg=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in -f) cfg="$2"; shift ;; esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
if [ -n "$cfg" ] && grep -q '{BROKEN_TOKEN}' "$cfg" 2>/dev/null; then
|
||||||
|
echo "[ALERT] parsing [$cfg:1] : unknown keyword '{BROKEN_TOKEN}'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
class RollbackTestCase(unittest.TestCase):
|
||||||
|
"""Base fixture: an isolated fake /etc/haproxy plus a stub haproxy binary."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = tempfile.mkdtemp(prefix='haproxy-rollback-test-')
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||||
|
|
||||||
|
bindir = os.path.join(self.tmp, 'bin')
|
||||||
|
os.makedirs(bindir)
|
||||||
|
stub = os.path.join(bindir, 'haproxy')
|
||||||
|
with open(stub, 'w') as fh:
|
||||||
|
fh.write(FAKE_HAPROXY)
|
||||||
|
os.chmod(stub, 0o755)
|
||||||
|
self._old_path = os.environ['PATH']
|
||||||
|
os.environ['PATH'] = bindir + os.pathsep + self._old_path
|
||||||
|
self.addCleanup(lambda: os.environ.__setitem__('PATH', self._old_path))
|
||||||
|
|
||||||
|
self.etc = os.path.join(self.tmp, 'etc')
|
||||||
|
os.makedirs(self.etc)
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
'DB_FILE': os.path.join(self.etc, 'haproxy_config.db'),
|
||||||
|
'HAPROXY_CONFIG_PATH': os.path.join(self.etc, 'haproxy.cfg'),
|
||||||
|
'HAPROXY_BACKUP_PATH': os.path.join(self.etc, 'haproxy.cfg.backup'),
|
||||||
|
'BLOCKED_IPS_MAP_PATH': os.path.join(self.etc, 'blocked_ips.map'),
|
||||||
|
'BLOCKED_IPS_MAP_BACKUP_PATH': os.path.join(self.etc, 'blocked_ips.map.backup'),
|
||||||
|
'CLUSTER_SECRET_PATH': os.path.join(self.etc, 'cluster-secret'),
|
||||||
|
'SSL_CERTS_DIR': os.path.join(self.etc, 'certs'),
|
||||||
|
'HAPROXY_SOCKET_PATH': os.path.join(self.etc, 'haproxy.sock'),
|
||||||
|
# Added by the rollback fix; older trees do not have it.
|
||||||
|
'CORAZA_SPOE_CONFIG_PATH': os.path.join(self.etc, 'coraza-spoe.cfg'),
|
||||||
|
'CORAZA_SPOE_BACKUP_PATH': os.path.join(self.etc, 'coraza-spoe.cfg.backup'),
|
||||||
|
}
|
||||||
|
self._saved = {}
|
||||||
|
for name, value in overrides.items():
|
||||||
|
self._saved[name] = getattr(hm, name, None)
|
||||||
|
setattr(hm, name, value)
|
||||||
|
self.addCleanup(self._restore_globals)
|
||||||
|
os.makedirs(hm.SSL_CERTS_DIR)
|
||||||
|
|
||||||
|
# log_operation() appends to a hardcoded /var/log path. Injecting `open`
|
||||||
|
# into the module namespace shadows the builtin for that module only
|
||||||
|
# (module globals are searched before builtins), so the real
|
||||||
|
# log_operation code still runs.
|
||||||
|
real_open = open
|
||||||
|
log_dir = self.tmp
|
||||||
|
|
||||||
|
def _redirecting_open(path, *args, **kwargs):
|
||||||
|
if isinstance(path, str) and path.startswith('/var/log/'):
|
||||||
|
path = os.path.join(log_dir, os.path.basename(path))
|
||||||
|
return real_open(path, *args, **kwargs)
|
||||||
|
|
||||||
|
hm.open = _redirecting_open
|
||||||
|
self.addCleanup(lambda: hm.__dict__.pop('open', None))
|
||||||
|
|
||||||
|
hm.init_db()
|
||||||
|
|
||||||
|
def _restore_globals(self):
|
||||||
|
for name, value in self._saved.items():
|
||||||
|
if value is None:
|
||||||
|
hm.__dict__.pop(name, None)
|
||||||
|
else:
|
||||||
|
setattr(hm, name, value)
|
||||||
|
|
||||||
|
# -- helpers ---------------------------------------------------------
|
||||||
|
def add_domain(self, domain, backend_name, address='10.0.0.1'):
|
||||||
|
with sqlite3.connect(hm.DB_FILE) as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute('INSERT INTO domains (domain, ssl_enabled) VALUES (?, 0)',
|
||||||
|
(domain,))
|
||||||
|
domain_id = cur.lastrowid
|
||||||
|
cur.execute('INSERT INTO backends (name, domain_id) VALUES (?, ?)',
|
||||||
|
(backend_name, domain_id))
|
||||||
|
backend_id = cur.lastrowid
|
||||||
|
cur.execute(
|
||||||
|
'INSERT INTO backend_servers '
|
||||||
|
'(backend_id, server_name, server_address, server_port) '
|
||||||
|
'VALUES (?, ?, ?, ?)',
|
||||||
|
(backend_id, 'srv1', address, 8080))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def block_ip(self, ip):
|
||||||
|
with sqlite3.connect(hm.DB_FILE) as conn:
|
||||||
|
conn.execute('INSERT INTO blocked_ips (ip_address, reason) VALUES (?, ?)',
|
||||||
|
(ip, 'test'))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def read(self, path):
|
||||||
|
with open(path) as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
def config_is_loadable(self):
|
||||||
|
"""True if HAProxy would accept the config currently on disk."""
|
||||||
|
import subprocess
|
||||||
|
return subprocess.run(
|
||||||
|
['haproxy', '-c', '-f', hm.HAPROXY_CONFIG_PATH],
|
||||||
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
def generate_good_config(self):
|
||||||
|
self.add_domain('good.example.com', 'good_backend')
|
||||||
|
hm.generate_config()
|
||||||
|
self.assertTrue(self.config_is_loadable(),
|
||||||
|
'fixture precondition: first generated config must be valid')
|
||||||
|
return self.read(hm.HAPROXY_CONFIG_PATH)
|
||||||
|
|
||||||
|
def break_the_config(self):
|
||||||
|
"""Queue a domain whose rendered backend the validator rejects."""
|
||||||
|
self.add_domain('bad.example.com', BROKEN_TOKEN + '_backend', '10.0.0.2')
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupOrdering(RollbackTestCase):
|
||||||
|
|
||||||
|
def test_backup_is_taken_before_the_new_config_is_written(self):
|
||||||
|
"""The ordering invariant, asserted directly.
|
||||||
|
|
||||||
|
Whatever create_backup() sees on disk must be the OLD config; if the
|
||||||
|
write happens first the backup is a copy of the new config and rollback
|
||||||
|
is meaningless.
|
||||||
|
"""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
|
||||||
|
seen = {}
|
||||||
|
real_create_backup = hm.create_backup
|
||||||
|
|
||||||
|
def spy(*args, **kwargs):
|
||||||
|
seen['config_on_disk'] = self.read(hm.HAPROXY_CONFIG_PATH)
|
||||||
|
return real_create_backup(*args, **kwargs)
|
||||||
|
|
||||||
|
hm.create_backup = spy
|
||||||
|
self.addCleanup(setattr, hm, 'create_backup', real_create_backup)
|
||||||
|
|
||||||
|
self.add_domain('second.example.com', 'second_backend', '10.0.0.3')
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
self.assertIn('config_on_disk', seen,
|
||||||
|
'create_backup() was never called during generate_config()')
|
||||||
|
self.assertEqual(
|
||||||
|
seen['config_on_disk'], good,
|
||||||
|
'create_backup() ran AFTER the new config was written - the backup '
|
||||||
|
'is a copy of the new config, so rollback cannot undo anything')
|
||||||
|
|
||||||
|
def test_backup_tracks_the_last_known_good_config(self):
|
||||||
|
"""After a change that validated AND loaded, the backup is that config.
|
||||||
|
|
||||||
|
The rollback target is "the last configuration HAProxy actually ran",
|
||||||
|
not "the file that happened to be there last time".
|
||||||
|
"""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
self.add_domain('second.example.com', 'second_backend', '10.0.0.3')
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
live = self.read(hm.HAPROXY_CONFIG_PATH)
|
||||||
|
self.assertNotEqual(live, good, 'fixture sanity: the new config should differ')
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), live,
|
||||||
|
'the successful config was not recorded as known-good')
|
||||||
|
|
||||||
|
def test_backup_is_not_promoted_when_the_change_fails(self):
|
||||||
|
"""A config that never loaded must not become the rollback target."""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
self.break_the_config()
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), good,
|
||||||
|
'a config that failed validation was promoted to backup')
|
||||||
|
|
||||||
|
|
||||||
|
class TestRollbackEndToEnd(RollbackTestCase):
|
||||||
|
|
||||||
|
def test_failed_validation_leaves_the_last_good_config_on_disk(self):
|
||||||
|
good = self.generate_good_config()
|
||||||
|
|
||||||
|
self.break_the_config()
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
on_disk = self.read(hm.HAPROXY_CONFIG_PATH)
|
||||||
|
self.assertNotIn(BROKEN_TOKEN, on_disk,
|
||||||
|
'the rejected config is still on disk - rollback was a no-op')
|
||||||
|
self.assertEqual(on_disk, good,
|
||||||
|
'on-disk config is not byte-identical to the last good one')
|
||||||
|
|
||||||
|
def test_haproxy_would_still_start_after_a_failed_change(self):
|
||||||
|
"""The operational consequence: the edge can still come up."""
|
||||||
|
self.generate_good_config()
|
||||||
|
self.break_the_config()
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
self.assertTrue(self.config_is_loadable(),
|
||||||
|
'HAProxy would refuse to start with the config left on disk')
|
||||||
|
with self.assertLogs('haproxy_manager', level='INFO') as captured:
|
||||||
|
hm.start_haproxy()
|
||||||
|
self.assertTrue(
|
||||||
|
any('HAProxy started successfully' in line for line in captured.output),
|
||||||
|
f'start_haproxy() did not succeed after rollback: {captured.output}')
|
||||||
|
|
||||||
|
def test_blocked_ips_map_is_rolled_back_too(self):
|
||||||
|
"""generate_config() rewrites the map file before writing haproxy.cfg."""
|
||||||
|
self.block_ip('192.0.2.10')
|
||||||
|
self.generate_good_config()
|
||||||
|
good_map = self.read(hm.BLOCKED_IPS_MAP_PATH)
|
||||||
|
|
||||||
|
self.block_ip('198.51.100.20')
|
||||||
|
self.break_the_config()
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
self.assertEqual(self.read(hm.BLOCKED_IPS_MAP_PATH), good_map,
|
||||||
|
'blocked IPs map was not rolled back with the config')
|
||||||
|
|
||||||
|
def test_first_run_failure_reports_that_rollback_was_impossible(self):
|
||||||
|
"""No prior config: there is nothing to restore, and that must be said.
|
||||||
|
|
||||||
|
A missing backup must never be reported as a successful restore, and it
|
||||||
|
must never be turned into "restore an empty file".
|
||||||
|
"""
|
||||||
|
self.break_the_config()
|
||||||
|
with self.assertRaises(Exception) as ctx:
|
||||||
|
hm.generate_config()
|
||||||
|
|
||||||
|
self.assertIn('ROLLBACK FAILED', str(ctx.exception),
|
||||||
|
'a failed change with no backup was not reported as such')
|
||||||
|
self.assertFalse(os.path.exists(hm.HAPROXY_BACKUP_PATH),
|
||||||
|
'a backup was fabricated from the broken config')
|
||||||
|
# The broken config is deliberately left in place: start_haproxy() can
|
||||||
|
# then detect it and try to regenerate. It must not be blanked.
|
||||||
|
self.assertGreater(os.path.getsize(hm.HAPROXY_CONFIG_PATH), 0,
|
||||||
|
'config file was emptied instead of left for diagnosis')
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupPrimitives(RollbackTestCase):
|
||||||
|
|
||||||
|
def test_restore_backup_distinguishes_missing_backup_from_success(self):
|
||||||
|
restored, message = hm.restore_backup()
|
||||||
|
self.assertFalse(restored,
|
||||||
|
'restore_backup() reported success with no backup present')
|
||||||
|
self.assertIn('cannot roll back', message.lower())
|
||||||
|
|
||||||
|
good = self.generate_good_config()
|
||||||
|
with open(hm.HAPROXY_CONFIG_PATH, 'w') as fh:
|
||||||
|
fh.write('scribbled over\n')
|
||||||
|
|
||||||
|
restored, message = hm.restore_backup()
|
||||||
|
self.assertTrue(restored, message)
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_CONFIG_PATH), good)
|
||||||
|
|
||||||
|
def test_a_successful_generation_records_a_rollback_target(self):
|
||||||
|
"""Even the first-ever generation must leave something to roll back to."""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(hm.HAPROXY_BACKUP_PATH),
|
||||||
|
'after a successful reload there is still no known-good backup')
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), good)
|
||||||
|
|
||||||
|
def test_a_broken_current_config_does_not_replace_a_good_backup(self):
|
||||||
|
"""The known-good marker.
|
||||||
|
|
||||||
|
If the config already on disk is broken (previous failed write, manual
|
||||||
|
edit), snapshotting it would make "rollback" mean "restore a different
|
||||||
|
broken config". The older validated backup must survive.
|
||||||
|
"""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), good,
|
||||||
|
'fixture: a good backup should exist by now')
|
||||||
|
|
||||||
|
with open(hm.HAPROXY_CONFIG_PATH, 'w') as fh:
|
||||||
|
fh.write(f'garbage {BROKEN_TOKEN} config\n')
|
||||||
|
|
||||||
|
ok, status = hm.create_backup()
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(status, 'kept_previous')
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), good,
|
||||||
|
'a broken config overwrote the known-good backup')
|
||||||
|
|
||||||
|
def test_reload_does_not_take_its_own_backup(self):
|
||||||
|
"""reload_haproxy_safely() runs after the write, so it must not back up."""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
with open(hm.HAPROXY_CONFIG_PATH, 'w') as fh:
|
||||||
|
fh.write(f'broken {BROKEN_TOKEN}\n')
|
||||||
|
|
||||||
|
success, message = hm.reload_haproxy_safely(backup_status='created')
|
||||||
|
|
||||||
|
self.assertFalse(success)
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), good,
|
||||||
|
'reload_haproxy_safely() overwrote the good backup')
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_CONFIG_PATH), good,
|
||||||
|
'reload_haproxy_safely() did not roll the config back')
|
||||||
|
|
||||||
|
def test_unchanged_config_is_not_revalidated(self):
|
||||||
|
"""Fast path: if the backup already is the live config, do no work.
|
||||||
|
|
||||||
|
generate_config() runs inside customer-facing API calls and
|
||||||
|
`haproxy -c` is expensive on an edge with hundreds of certificates.
|
||||||
|
"""
|
||||||
|
self.generate_good_config()
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
real_validate = hm.validate_config_file
|
||||||
|
hm.validate_config_file = lambda path: (calls.append(path),
|
||||||
|
real_validate(path))[1]
|
||||||
|
self.addCleanup(setattr, hm, 'validate_config_file', real_validate)
|
||||||
|
|
||||||
|
ok, status = hm.create_backup()
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(status, 'created')
|
||||||
|
self.assertEqual(calls, [],
|
||||||
|
'the unchanged live config was re-validated needlessly')
|
||||||
|
|
||||||
|
def test_fast_path_does_not_hide_a_drifted_broken_config(self):
|
||||||
|
"""If the live config drifted from the backup, the gate must still run."""
|
||||||
|
good = self.generate_good_config()
|
||||||
|
with open(hm.HAPROXY_CONFIG_PATH, 'w') as fh:
|
||||||
|
fh.write(f'hand edited {BROKEN_TOKEN}\n')
|
||||||
|
|
||||||
|
ok, status = hm.create_backup()
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(status, 'kept_previous',
|
||||||
|
'a drifted broken config was silently accepted')
|
||||||
|
self.assertEqual(self.read(hm.HAPROXY_BACKUP_PATH), good)
|
||||||
|
|
||||||
|
def test_backup_set_covers_every_file_generate_config_writes(self):
|
||||||
|
pairs = dict(hm._config_backup_pairs())
|
||||||
|
for path in (hm.HAPROXY_CONFIG_PATH, hm.BLOCKED_IPS_MAP_PATH,
|
||||||
|
hm.CORAZA_SPOE_CONFIG_PATH):
|
||||||
|
self.assertIn(path, pairs,
|
||||||
|
f'{path} is written by generate_config() but is not '
|
||||||
|
'part of the backed-up config set')
|
||||||
|
|
||||||
|
def test_coraza_spoe_config_round_trips(self):
|
||||||
|
self.generate_good_config()
|
||||||
|
with open(hm.CORAZA_SPOE_CONFIG_PATH, 'w') as fh:
|
||||||
|
fh.write('spoe-good\n')
|
||||||
|
hm.create_backup()
|
||||||
|
with open(hm.CORAZA_SPOE_CONFIG_PATH, 'w') as fh:
|
||||||
|
fh.write('spoe-broken\n')
|
||||||
|
restored, message = hm.restore_backup()
|
||||||
|
self.assertTrue(restored, message)
|
||||||
|
self.assertEqual(self.read(hm.CORAZA_SPOE_CONFIG_PATH), 'spoe-good\n')
|
||||||
|
|
||||||
|
|
||||||
|
class TestAtomicWrite(RollbackTestCase):
|
||||||
|
|
||||||
|
def test_write_is_atomic_and_preserves_mode(self):
|
||||||
|
path = os.path.join(self.etc, 'atomic.cfg')
|
||||||
|
with open(path, 'w') as fh:
|
||||||
|
fh.write('old')
|
||||||
|
os.chmod(path, 0o644)
|
||||||
|
|
||||||
|
hm.write_config_atomically(path, 'new content\n')
|
||||||
|
|
||||||
|
self.assertEqual(self.read(path), 'new content\n')
|
||||||
|
self.assertEqual(oct(os.stat(path).st_mode & 0o777), oct(0o644))
|
||||||
|
leftovers = [n for n in os.listdir(self.etc) if n.endswith('.tmp')]
|
||||||
|
self.assertEqual(leftovers, [], f'temp files left behind: {leftovers}')
|
||||||
|
|
||||||
|
def test_failed_write_leaves_the_previous_file_intact(self):
|
||||||
|
path = os.path.join(self.etc, 'atomic.cfg')
|
||||||
|
with open(path, 'w') as fh:
|
||||||
|
fh.write('old content\n')
|
||||||
|
|
||||||
|
# Anything that makes f.write() blow up mid-flight stands in for a full
|
||||||
|
# disk / killed container.
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
hm.write_config_atomically(path, object())
|
||||||
|
|
||||||
|
self.assertEqual(self.read(path), 'old content\n',
|
||||||
|
'a failed write clobbered the previous config')
|
||||||
|
leftovers = [n for n in os.listdir(self.etc) if n.endswith('.tmp')]
|
||||||
|
self.assertEqual(leftovers, [], f'temp files left behind: {leftovers}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print(f"testing haproxy_manager from: {MODULE_DIR}")
|
||||||
|
unittest.main(verbosity=2)
|
||||||
Executable
+184
@@ -0,0 +1,184 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# HAProxy Manager IP Blocking Test Script
|
||||||
|
# This script tests the IP blocking functionality
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:8000"
|
||||||
|
API_KEY="${HAPROXY_API_KEY:-}"
|
||||||
|
TEST_IP="192.168.100.50"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to print colored output
|
||||||
|
print_status() {
|
||||||
|
local status=$1
|
||||||
|
local message=$2
|
||||||
|
|
||||||
|
if [ "$status" = "PASS" ]; then
|
||||||
|
echo -e "${GREEN}✓ PASS${NC}: $message"
|
||||||
|
elif [ "$status" = "FAIL" ]; then
|
||||||
|
echo -e "${RED}✗ FAIL${NC}: $message"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}? INFO${NC}: $message"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to make API request
|
||||||
|
api_request() {
|
||||||
|
local method=$1
|
||||||
|
local endpoint=$2
|
||||||
|
local data=$3
|
||||||
|
|
||||||
|
local headers=""
|
||||||
|
if [ -n "$API_KEY" ]; then
|
||||||
|
headers="-H \"Authorization: Bearer $API_KEY\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$data" ]; then
|
||||||
|
headers="$headers -H \"Content-Type: application/json\" -d '$data'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "curl -s -w '\n%{http_code}' $headers -X $method $BASE_URL$endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "HAProxy Manager IP Blocking Test Suite"
|
||||||
|
echo "======================================"
|
||||||
|
echo "Base URL: $BASE_URL"
|
||||||
|
echo "API Key: ${API_KEY:-"Not configured"}"
|
||||||
|
echo "Test IP: $TEST_IP"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 1: Get current blocked IPs
|
||||||
|
print_status "INFO" "Testing GET /api/blocked-ips endpoint..."
|
||||||
|
response=$(api_request "GET" "/api/blocked-ips")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [ "$http_code" = "200" ] || [ "$http_code" = "401" ]; then
|
||||||
|
print_status "PASS" "Get blocked IPs endpoint working (status: $http_code)"
|
||||||
|
echo "Current blocked IPs: $body"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Get blocked IPs failed with status $http_code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 2: Block an IP
|
||||||
|
print_status "INFO" "Testing POST /api/blocked-ips endpoint..."
|
||||||
|
block_data='{
|
||||||
|
"ip_address": "'$TEST_IP'",
|
||||||
|
"reason": "Test blocking from script",
|
||||||
|
"blocked_by": "Test Script"
|
||||||
|
}'
|
||||||
|
|
||||||
|
response=$(api_request "POST" "/api/blocked-ips" "$block_data")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
|
||||||
|
print_status "PASS" "Block IP endpoint working - IP $TEST_IP blocked"
|
||||||
|
echo "Response: $body"
|
||||||
|
elif [ "$http_code" = "409" ]; then
|
||||||
|
print_status "INFO" "IP $TEST_IP is already blocked"
|
||||||
|
elif [ "$http_code" = "401" ]; then
|
||||||
|
print_status "FAIL" "Authentication required (check API key)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Block IP failed with status $http_code"
|
||||||
|
echo "Response: $body"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 3: Try to block same IP again (should get 409)
|
||||||
|
print_status "INFO" "Testing duplicate block (should fail)..."
|
||||||
|
response=$(api_request "POST" "/api/blocked-ips" "$block_data")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
|
||||||
|
if [ "$http_code" = "409" ]; then
|
||||||
|
print_status "PASS" "Duplicate block correctly rejected with 409"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Unexpected status $http_code for duplicate block"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 4: Get blocked IPs to verify our IP is there
|
||||||
|
print_status "INFO" "Verifying IP is in blocked list..."
|
||||||
|
response=$(api_request "GET" "/api/blocked-ips")
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if echo "$body" | grep -q "$TEST_IP"; then
|
||||||
|
print_status "PASS" "IP $TEST_IP found in blocked list"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "IP $TEST_IP not found in blocked list"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 5: Unblock the IP
|
||||||
|
print_status "INFO" "Testing DELETE /api/blocked-ips endpoint..."
|
||||||
|
unblock_data='{"ip_address": "'$TEST_IP'"}'
|
||||||
|
|
||||||
|
response=$(api_request "DELETE" "/api/blocked-ips" "$unblock_data")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
body=$(echo "$response" | head -n -1)
|
||||||
|
|
||||||
|
if [ "$http_code" = "200" ]; then
|
||||||
|
print_status "PASS" "Unblock IP endpoint working - IP $TEST_IP unblocked"
|
||||||
|
echo "Response: $body"
|
||||||
|
elif [ "$http_code" = "404" ]; then
|
||||||
|
print_status "INFO" "IP $TEST_IP was not in blocked list"
|
||||||
|
elif [ "$http_code" = "401" ]; then
|
||||||
|
print_status "FAIL" "Authentication required (check API key)"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Unblock IP failed with status $http_code"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 6: Try to unblock non-existent IP (should get 404)
|
||||||
|
print_status "INFO" "Testing unblock of non-existent IP..."
|
||||||
|
fake_data='{"ip_address": "1.2.3.4"}'
|
||||||
|
response=$(api_request "DELETE" "/api/blocked-ips" "$fake_data")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
|
||||||
|
if [ "$http_code" = "404" ]; then
|
||||||
|
print_status "PASS" "Non-existent IP correctly returned 404"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Unexpected status $http_code for non-existent IP"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test 7: Test missing IP address in request
|
||||||
|
print_status "INFO" "Testing requests with missing IP address..."
|
||||||
|
invalid_data='{}'
|
||||||
|
|
||||||
|
response=$(api_request "POST" "/api/blocked-ips" "$invalid_data")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
if [ "$http_code" = "400" ]; then
|
||||||
|
print_status "PASS" "Block request with missing IP correctly returned 400"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Unexpected status $http_code for missing IP in block request"
|
||||||
|
fi
|
||||||
|
|
||||||
|
response=$(api_request "DELETE" "/api/blocked-ips" "$invalid_data")
|
||||||
|
http_code=$(echo "$response" | tail -n 1)
|
||||||
|
if [ "$http_code" = "400" ]; then
|
||||||
|
print_status "PASS" "Unblock request with missing IP correctly returned 400"
|
||||||
|
else
|
||||||
|
print_status "FAIL" "Unexpected status $http_code for missing IP in unblock request"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================"
|
||||||
|
echo "IP Blocking tests completed"
|
||||||
|
echo ""
|
||||||
|
echo "To manually test the blocked page:"
|
||||||
|
echo "1. Block an IP: curl -X POST $BASE_URL/api/blocked-ips -H 'Authorization: Bearer YOUR_KEY' -H 'Content-Type: application/json' -d '{\"ip_address\": \"YOUR_IP\"}'"
|
||||||
|
echo "2. Access any domain through HAProxy from that IP"
|
||||||
|
echo "3. You should see the 'Access Denied' page"
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Access Denied</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #e74c3c;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.ip-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
.error-code {
|
||||||
|
background: #ffebee;
|
||||||
|
border: 1px solid #ffcdd2;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: #c62828;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<span class="icon">🚫</span>
|
||||||
|
<h1>Access Denied</h1>
|
||||||
|
<p>Your IP address has been blocked from accessing this website.</p>
|
||||||
|
<p>If you believe this block has been made in error, please contact support for assistance.</p>
|
||||||
|
|
||||||
|
<div class="error-code">
|
||||||
|
Error Code: 403 - Forbidden
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ip-info">
|
||||||
|
<strong>Your IP:</strong> <span id="client-ip"></span><br>
|
||||||
|
<strong>Time:</strong> <span id="timestamp"></span><br>
|
||||||
|
<strong>Domain:</strong> <span id="domain"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 20px; margin-top: 25px; color: #495057; text-align: left;">
|
||||||
|
<strong>To request unblocking:</strong><br>
|
||||||
|
• Contact your hosting provider's support team<br>
|
||||||
|
• Provide your IP address and the domain you're trying to access<br>
|
||||||
|
• Explain why you believe this block is in error
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Display the current domain and timestamp
|
||||||
|
document.getElementById('domain').textContent = window.location.hostname;
|
||||||
|
document.getElementById('timestamp').textContent = new Date().toLocaleString();
|
||||||
|
|
||||||
|
// Attempt to get client IP (this will show the proxy IP in most cases)
|
||||||
|
// For actual client IP, this would need to be injected by the server
|
||||||
|
document.getElementById('client-ip').textContent = 'Hidden for privacy';
|
||||||
|
|
||||||
|
// You could also make an AJAX call to get the real client IP if needed
|
||||||
|
// fetch('/api/my-ip').then(r => r.json()).then(data => {
|
||||||
|
// document.getElementById('client-ip').textContent = data.ip;
|
||||||
|
// }).catch(() => {
|
||||||
|
// document.getElementById('client-ip').textContent = 'Unable to determine';
|
||||||
|
// });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ page_title }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #e74c3c;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.2em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
.contact {
|
||||||
|
background: linear-gradient(135deg, #3498db, #2980b9);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 25px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.contact:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
|
||||||
|
}
|
||||||
|
.domain-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<span class="icon">⚠️</span>
|
||||||
|
<h1>{{ page_title }}</h1>
|
||||||
|
<p>{{ main_message }}</p>
|
||||||
|
<p>{{ secondary_message }}</p>
|
||||||
|
|
||||||
|
<div class="domain-info">
|
||||||
|
<strong>Domain:</strong> <span id="domain"></span><br>
|
||||||
|
<strong>Time:</strong> <span id="timestamp"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Display the current domain and timestamp
|
||||||
|
document.getElementById('domain').textContent = window.location.hostname;
|
||||||
|
document.getElementById('timestamp').textContent = new Date().toLocaleString();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,40 @@
|
|||||||
|
|
||||||
|
# Regular HTTP backend - uses http-server-close for better security and connection management
|
||||||
backend {{ name }}-backend
|
backend {{ name }}-backend
|
||||||
option forwardfor
|
option forwardfor
|
||||||
http-request add-header X-CLIENT-IP %[src]
|
# Pass the real client IP to backend (from proxy headers or direct connection)
|
||||||
{% if ssl_enabled %}http-request set-header X-Forwarded-Proto https if { ssl_fc }{% endif %}
|
# This is crucial for container-level logging and security tools
|
||||||
{% for server in servers %}server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }}{% endfor %}
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
|
||||||
|
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
# SSE-specific backend - optimized for Server-Sent Events long-lived connections
|
||||||
|
backend {{ name }}-sse-backend
|
||||||
|
# Disable http-server-close to allow SSE long-lived connections
|
||||||
|
no option http-server-close
|
||||||
|
|
||||||
|
# Enable http-no-delay for immediate data transmission
|
||||||
|
option http-no-delay
|
||||||
|
|
||||||
|
# Extended timeouts to support SSE long-lived connections (up to 6 hours)
|
||||||
|
# Note: SSE sends keepalives every 1 second, so timeout only triggers if backend hangs
|
||||||
|
timeout server 6h
|
||||||
|
timeout http-keep-alive 6h
|
||||||
|
|
||||||
|
option forwardfor
|
||||||
|
# Pass the real client IP to backend (from proxy headers or direct connection)
|
||||||
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
|
||||||
|
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
|
||||||
|
{% endfor %}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
|
|
||||||
backend {{ name }}-backend
|
backend {{ name }}-backend
|
||||||
{% for server in servers %}server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }}{% endfor %}
|
option forwardfor
|
||||||
|
# Pass the real client IP to backend (from proxy headers or direct connection)
|
||||||
|
# This is crucial for container-level logging and security tools
|
||||||
|
# http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
# http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
# http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
@@ -2,7 +2,14 @@
|
|||||||
backend {{ name }}-backend
|
backend {{ name }}-backend
|
||||||
option forwardfor
|
option forwardfor
|
||||||
option httpchk
|
option httpchk
|
||||||
http-request add-header X-CLIENT-IP %[src]
|
# Pass the real client IP to backend (from proxy headers or direct connection)
|
||||||
|
# This is crucial for container-level logging and security tools
|
||||||
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
{% if ssl_enabled %}http-request set-header X-Forwarded-Proto https if { ssl_fc }{% endif %}
|
{% if ssl_enabled %}http-request set-header X-Forwarded-Proto https if { ssl_fc }{% endif %}
|
||||||
{% for server in servers %}server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }}{% endfor %}
|
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Long-lived backend for {{ name }} (template_override='hap_backend_longlived').
|
||||||
|
# Use for apps whose PRIMARY traffic holds connections open: media streaming,
|
||||||
|
# large up/downloads, or persistent viewer/streaming sessions. Both the primary
|
||||||
|
# and the SSE backend are tuned long-lived here (no http-server-close,
|
||||||
|
# http-no-delay, 6h server/tunnel/keep-alive timeouts).
|
||||||
|
#
|
||||||
|
# Compare hap_backend_websocket.tpl, which keeps the PRIMARY backend standard
|
||||||
|
# and only makes the -sse-backend long-lived. Pick this one when the main path
|
||||||
|
# itself needs long-lived connections, not just an SSE side-channel.
|
||||||
|
backend {{ name }}-backend
|
||||||
|
no option http-server-close
|
||||||
|
option http-no-delay
|
||||||
|
timeout server 6h
|
||||||
|
timeout tunnel 6h
|
||||||
|
timeout http-keep-alive 6h
|
||||||
|
option forwardfor
|
||||||
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
# SSE variant (Accept: text/event-stream / ?action=stream auto-routes here)
|
||||||
|
backend {{ name }}-sse-backend
|
||||||
|
no option http-server-close
|
||||||
|
option http-no-delay
|
||||||
|
timeout server 6h
|
||||||
|
timeout tunnel 6h
|
||||||
|
timeout http-keep-alive 6h
|
||||||
|
option forwardfor
|
||||||
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Long-lived / websocket-safe backend for {{ name }} (template_override)
|
||||||
|
# For apps with persistent WebSocket/streaming connections (e.g. Jitsi /xmpp-websocket, /colibri-ws).
|
||||||
|
backend {{ name }}-backend
|
||||||
|
no option http-server-close
|
||||||
|
option http-no-delay
|
||||||
|
timeout server 6h
|
||||||
|
timeout tunnel 6h
|
||||||
|
timeout http-keep-alive 6h
|
||||||
|
option forwardfor
|
||||||
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
# SSE variant (Accept: text/event-stream / ?action=stream auto-routes here)
|
||||||
|
backend {{ name }}-sse-backend
|
||||||
|
no option http-server-close
|
||||||
|
option http-no-delay
|
||||||
|
timeout server 6h
|
||||||
|
timeout tunnel 6h
|
||||||
|
timeout http-keep-alive 6h
|
||||||
|
option forwardfor
|
||||||
|
http-request add-header X-CLIENT-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Real-IP %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-For %[var(txn.real_ip)]
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Proto http if !{ ssl_fc }
|
||||||
|
{% for server in servers %}
|
||||||
|
server {{ server.server_name }} {{ server.server_address }}:{{ server.server_port }} {{ server.server_options }} resolvers docker_dns init-addr last,libc,none
|
||||||
|
{% endfor %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Coraza-SPOA backend.
|
||||||
|
# Only rendered into haproxy.cfg when HAPROXY_CORAZA_SPOE_BACKEND env var is
|
||||||
|
# set on the haproxy-manager container. SPOE traffic to this backend is TCP,
|
||||||
|
# not HTTP. The agent target comes from the env var so a single image can be
|
||||||
|
# deployed against different sidecar host:port pairs (typically the sidecar
|
||||||
|
# container's name + 9000 inside the shared docker network).
|
||||||
|
backend coraza-spoa-backend
|
||||||
|
mode tcp
|
||||||
|
# spop-check actually speaks the SPOE protocol against the agent —
|
||||||
|
# confirms the agent can negotiate a session, not just that the TCP
|
||||||
|
# port is open. Required to detect a half-broken SPOA that's listening
|
||||||
|
# but not actually processing.
|
||||||
|
option spop-check
|
||||||
|
timeout connect 5s
|
||||||
|
timeout server 30s
|
||||||
|
server coraza-spoa {{ agent_target }} check
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Coraza SPOE engine configuration.
|
||||||
|
#
|
||||||
|
# Written to /etc/haproxy/coraza-spoe.cfg by haproxy_manager.generate_config()
|
||||||
|
# when HAPROXY_CORAZA_SPOE_BACKEND env var is set. Referenced from haproxy.cfg
|
||||||
|
# via `filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg`.
|
||||||
|
#
|
||||||
|
# Engine name "coraza" must match the engine name in the filter line in the
|
||||||
|
# main config; group name "coraza-req" must match the send-spoe-group action.
|
||||||
|
# Application name "haproxy" must match the application block in coraza-spoa's
|
||||||
|
# config.yaml.
|
||||||
|
#
|
||||||
|
# Reference: this config follows the shape from coraza-spoa's upstream
|
||||||
|
# example/haproxy/coraza.cfg (v0.7.1). Arg names + ordering are required by
|
||||||
|
# Coraza-SPOA exactly as specified — DO NOT reorder or rename without
|
||||||
|
# coordinating with the agent.
|
||||||
|
|
||||||
|
[coraza]
|
||||||
|
|
||||||
|
spoe-agent coraza
|
||||||
|
# `groups` (not `messages`) lists the spoe-group names this engine offers
|
||||||
|
# via `send-spoe-group` actions. The same group name appears below in a
|
||||||
|
# spoe-group block, which in turn references the actual message.
|
||||||
|
groups coraza-req
|
||||||
|
|
||||||
|
# Prefix for variables the agent sets back on the request transaction —
|
||||||
|
# e.g. var(txn.coraza.error) when set-on-error triggers.
|
||||||
|
option var-prefix coraza
|
||||||
|
|
||||||
|
# On agent error/timeout, set var(txn.coraza.error). We DON'T add a
|
||||||
|
# corresponding `http-request deny if { var(txn.coraza.error) -m bool }`
|
||||||
|
# in the frontend, so the request continues uninspected. This is the
|
||||||
|
# fail-open posture: WAF outage shouldn't 503 customer traffic.
|
||||||
|
option set-on-error error
|
||||||
|
|
||||||
|
timeout hello 2s
|
||||||
|
timeout idle 2m
|
||||||
|
timeout processing 100ms
|
||||||
|
|
||||||
|
use-backend coraza-spoa-backend
|
||||||
|
log global
|
||||||
|
|
||||||
|
# Per-request inspection message. No `event` directive — fires only when
|
||||||
|
# explicitly invoked from haproxy.cfg via `http-request send-spoe-group`.
|
||||||
|
# Arg order/names are mandatory: Coraza-SPOA parses positionally and renames
|
||||||
|
# break the agent. `app=str(haproxy)` is the literal application name from
|
||||||
|
# coraza-spoa's config.yaml `applications:` block.
|
||||||
|
#
|
||||||
|
# src-ip uses var(txn.real_ip) — HAProxy resolves the real client IP at the
|
||||||
|
# top of the frontend (CF-Connecting-IP > X-Real-IP > X-Forwarded-For > src,
|
||||||
|
# only honoring those headers from trusted proxies). Falls back to `src`
|
||||||
|
# when no proxy headers are present. This is what shows up as `client_ip`
|
||||||
|
# in /var/log/coraza/audit.log and the rule manager's view-matches panel.
|
||||||
|
spoe-message coraza-req
|
||||||
|
args app=str(haproxy) src-ip=var(txn.real_ip) src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body
|
||||||
|
|
||||||
|
# Group binding for send-spoe-group invocation in the frontend. One group,
|
||||||
|
# one message; could add more in the future (e.g. coraza-res for response
|
||||||
|
# inspection — currently disabled in coraza-spoa's config.yaml).
|
||||||
|
spoe-group coraza-req
|
||||||
|
messages coraza-req
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Default backend for unmatched domains
|
||||||
|
backend default-backend
|
||||||
|
mode http
|
||||||
|
option http-server-close
|
||||||
|
http-request set-header X-Forwarded-Proto https if { ssl_fc }
|
||||||
|
http-request set-header X-Forwarded-Port %[dst_port]
|
||||||
|
http-request set-header X-Forwarded-For %[src]
|
||||||
|
http-request set-header X-Real-IP %[src]
|
||||||
|
|
||||||
|
# Serve the default page HTML response using a local server
|
||||||
|
server default-page 127.0.0.1:8080
|
||||||
@@ -24,7 +24,50 @@ global
|
|||||||
group haproxy
|
group haproxy
|
||||||
daemon
|
daemon
|
||||||
|
|
||||||
|
# SSL and Performance
|
||||||
tune.ssl.default-dh-param 2048
|
tune.ssl.default-dh-param 2048
|
||||||
|
|
||||||
|
# HTTP/3 over QUIC. The Debian haproxy package is built against system
|
||||||
|
# OpenSSL via the compatibility shim (USE_QUIC_OPENSSL_COMPAT), which is
|
||||||
|
# not a native QUIC TLS stack. HAProxy therefore rejects `quic*@` binds
|
||||||
|
# unless this opt-in is set. `limited-quic` enables QUIC through the compat
|
||||||
|
# layer (no 0-RTT — that needs quictls/aws-lc or native OpenSSL 3.5 QUIC).
|
||||||
|
# Without this, the quic bind in the frontend fails to start: "this SSL
|
||||||
|
# library does not support the QUIC protocol".
|
||||||
|
limited-quic
|
||||||
|
{%- if cluster_secret %}
|
||||||
|
|
||||||
|
# Stable secret keying QUIC Retry/address-validation tokens. Self-healed
|
||||||
|
# to /etc/haproxy/cluster-secret (named volume) by the manager so it
|
||||||
|
# survives recreates; without it haproxy picks a random one per process
|
||||||
|
# and tokens don't survive reloads (benign, just a startup notice).
|
||||||
|
cluster-secret "{{ cluster_secret }}"
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
# HTTP/2 protection against Rapid Reset (CVE-2023-44487) and stream abuse
|
||||||
|
tune.h2.fe.max-total-streams 2000
|
||||||
|
tune.h2.fe.glitches-threshold 50
|
||||||
|
|
||||||
|
# Stats persistence for zero-downtime reloads
|
||||||
|
stats-file /var/lib/haproxy/stats.dat
|
||||||
|
|
||||||
|
#---------------------------------------------------------------------
|
||||||
|
# DNS resolver for Docker container name resolution
|
||||||
|
# Re-resolves backend server addresses so container IP changes
|
||||||
|
# (from restarts, recreations, scaling) are picked up automatically
|
||||||
|
#---------------------------------------------------------------------
|
||||||
|
resolvers docker_dns
|
||||||
|
nameserver dns1 127.0.0.11:53
|
||||||
|
resolve_retries 3
|
||||||
|
timeout resolve 1s
|
||||||
|
timeout retry 1s
|
||||||
|
hold valid 10s
|
||||||
|
hold other 10s
|
||||||
|
hold refused 10s
|
||||||
|
hold nx 10s
|
||||||
|
hold timeout 10s
|
||||||
|
hold obsolete 10s
|
||||||
|
|
||||||
#---------------------------------------------------------------------
|
#---------------------------------------------------------------------
|
||||||
# common defaults that all the 'listen' and 'backend' sections will
|
# common defaults that all the 'listen' and 'backend' sections will
|
||||||
# use if not designated in their block
|
# use if not designated in their block
|
||||||
@@ -38,12 +81,24 @@ defaults
|
|||||||
option forwardfor #except 127.0.0.0/8
|
option forwardfor #except 127.0.0.0/8
|
||||||
option redispatch
|
option redispatch
|
||||||
retries 3
|
retries 3
|
||||||
timeout http-request 300s
|
timeout http-request 30s
|
||||||
timeout queue 2m
|
timeout queue 2m
|
||||||
timeout connect 120s
|
timeout connect 10s
|
||||||
timeout client 10m
|
timeout client 5m
|
||||||
timeout server 10m
|
timeout server 10m
|
||||||
timeout http-keep-alive 120s
|
timeout http-keep-alive 30s
|
||||||
timeout check 10s
|
timeout check 10s
|
||||||
|
timeout tarpit 10s # Tarpit delay for low-level scanners (before silent-drop)
|
||||||
maxconn 3000
|
maxconn 3000
|
||||||
|
|
||||||
|
# Per-request unique reference, used:
|
||||||
|
# - in the log line (httplog includes %ID)
|
||||||
|
# - echoed to clients in the X-Request-Reference response header on
|
||||||
|
# WAF blocks so a customer can quote it when opening a support ticket
|
||||||
|
# - embedded in /etc/haproxy/errors/403-waf.html so a blocked visitor
|
||||||
|
# sees it on the rendered 403 page
|
||||||
|
# Support correlates ref → /var/log/haproxy.log line → timestamp+client+host
|
||||||
|
# → /var/log/coraza/audit.log entry → rule_id.
|
||||||
|
unique-id-format %[uuid()]
|
||||||
|
unique-id-header X-Request-Reference
|
||||||
|
|
||||||
@@ -3,3 +3,182 @@ frontend web
|
|||||||
bind 0.0.0.0:80
|
bind 0.0.0.0:80
|
||||||
# crt can now be a path, so it will load all .pem files in the path
|
# crt can now be a path, so it will load all .pem files in the path
|
||||||
bind 0.0.0.0:443 ssl crt {{ crt_path }} alpn h2,http/1.1
|
bind 0.0.0.0:443 ssl crt {{ crt_path }} alpn h2,http/1.1
|
||||||
|
|
||||||
|
# HTTP/3 over QUIC (UDP/443). Same cert path as the TCP listener above.
|
||||||
|
# The Debian haproxy package is built +QUIC (QUIC_OPENSSL_COMPAT), so this
|
||||||
|
# is config-only — no source build. Requires UDP/443 published on the
|
||||||
|
# container (`-p 443:443/udp`) and open at the host firewall. `h3` is the
|
||||||
|
# only ALPN QUIC negotiates; h2/http1 stay on the TCP bind above. Sharing
|
||||||
|
# the frontend means all the real-IP, rate-limit, IP-block and Coraza
|
||||||
|
# rules below apply identically to H3 traffic.
|
||||||
|
bind quic4@0.0.0.0:443 ssl crt {{ crt_path }} alpn h3
|
||||||
|
|
||||||
|
# Advertise H3 so browsers upgrade their existing TCP (h2) connection to
|
||||||
|
# QUIC on the next request. `ma` is how long (seconds) the client may
|
||||||
|
# cache the advertisement. http-after-response applies it to every
|
||||||
|
# response, including haproxy-generated ones (blocks, default page).
|
||||||
|
http-after-response set-header alt-svc "h3=\":443\"; ma=86400"
|
||||||
|
|
||||||
|
# Capture Host header so it appears in httplog output (in %hr field)
|
||||||
|
http-request capture req.hdr(Host) len 64
|
||||||
|
|
||||||
|
# Detect real client IP from proxy headers if they exist
|
||||||
|
# Priority: CF-Connecting-IP (Cloudflare) > X-Real-IP > X-Forwarded-For > src
|
||||||
|
acl has_cf_connecting_ip req.hdr(CF-Connecting-IP) -m found
|
||||||
|
acl has_x_real_ip req.hdr(X-Real-IP) -m found
|
||||||
|
acl has_x_forwarded_for req.hdr(X-Forwarded-For) -m found
|
||||||
|
|
||||||
|
# Set the real IP based on available headers. Use hdr_ip (not hdr) so the
|
||||||
|
# variable is typed as IP — required by the Coraza SPOE arg `src-ip` which
|
||||||
|
# decodes binary IP bytes (passing a string IP panics the SPOA goroutine).
|
||||||
|
# `hdr_ip(X-Forwarded-For,1)` extracts the FIRST address from a possibly
|
||||||
|
# comma-separated chain (original client, not intermediate proxies).
|
||||||
|
http-request set-var(txn.real_ip) req.hdr_ip(CF-Connecting-IP) if has_cf_connecting_ip
|
||||||
|
http-request set-var(txn.real_ip) req.hdr_ip(X-Real-IP) if !has_cf_connecting_ip has_x_real_ip
|
||||||
|
http-request set-var(txn.real_ip) req.hdr_ip(X-Forwarded-For,1) if !has_cf_connecting_ip !has_x_real_ip has_x_forwarded_for
|
||||||
|
http-request set-var(txn.real_ip) src if !has_cf_connecting_ip !has_x_real_ip !has_x_forwarded_for
|
||||||
|
|
||||||
|
# --- Connection & rate tracking ---
|
||||||
|
stick-table type ip size 200k expire 10m store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(30s)
|
||||||
|
http-request track-sc0 var(txn.real_ip)
|
||||||
|
|
||||||
|
# Whitelist: let health checks, local, and trusted traffic bypass rate limits
|
||||||
|
acl is_local src 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
|
||||||
|
acl is_trusted_ip src -f /etc/haproxy/trusted_ips.list
|
||||||
|
acl is_health_check path_beg /.well-known/acme-challenge
|
||||||
|
acl is_whitelisted var(txn.real_ip),map_ip(/etc/haproxy/trusted_ips.map,0) -m int gt 0
|
||||||
|
|
||||||
|
# --- Rate limit rules (applied in order, first match wins) ---
|
||||||
|
# Thresholds are generous to accommodate media-heavy sites where a
|
||||||
|
# single page can load 100+ images/assets. These only trigger on
|
||||||
|
# obvious automated abuse, not real users.
|
||||||
|
#
|
||||||
|
# Hard block: >5000 req/10s per IP (500 req/s — sustained flood)
|
||||||
|
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 5000 } !is_local !is_trusted_ip !is_whitelisted !is_health_check
|
||||||
|
# Tarpit: >3000 req/10s per IP (300 req/s — aggressive bot/scraper)
|
||||||
|
http-request tarpit deny_status 429 if { sc_http_req_rate(0) gt 3000 } !is_local !is_trusted_ip !is_whitelisted !is_health_check
|
||||||
|
# Connection rate limit: >500 new connections per 10s per IP
|
||||||
|
http-request deny deny_status 429 if { sc_conn_rate(0) gt 500 } !is_local !is_trusted_ip !is_whitelisted !is_health_check
|
||||||
|
# Concurrent connection limit: >500 simultaneous connections per IP
|
||||||
|
http-request deny deny_status 429 if { sc_conn_cur(0) gt 500 } !is_local !is_trusted_ip !is_whitelisted !is_health_check
|
||||||
|
# High error rate: >100 errors in 30s (scanner/fuzzer behavior)
|
||||||
|
http-request tarpit deny_status 403 if { sc_http_err_rate(0) gt 100 } !is_local !is_trusted_ip !is_whitelisted !is_health_check
|
||||||
|
|
||||||
|
# --- WordPress wp-login.php brute-force protection ---
|
||||||
|
# The generic limits above are deliberately high (media-heavy sites), so a
|
||||||
|
# slow credential-stuffing run (dozens of login POSTs/min) slips under them.
|
||||||
|
# Track POSTs to wp-login.php per real client IP in a DEDICATED 60s table
|
||||||
|
# (sc1 / backend wp_bruteforce, defined in hap_security_tables.tpl) and
|
||||||
|
# tarpit once an IP exceeds 30/min. Only login POSTs are counted — GETs of
|
||||||
|
# the login form, normal browsing, and the handful of POSTs a legit user
|
||||||
|
# makes are unaffected; an offending IP can still browse, just not keep
|
||||||
|
# hammering login. path_end also covers subdirectory WP installs. Honors the
|
||||||
|
# same whitelist (RFC1918 / trusted_ips.list / trusted_ips.map).
|
||||||
|
acl wp_login_path path_end /wp-login.php
|
||||||
|
http-request track-sc1 var(txn.real_ip) table wp_bruteforce if METH_POST wp_login_path
|
||||||
|
http-request tarpit deny_status 429 if METH_POST wp_login_path { sc_http_req_rate(1) gt 30 } !is_local !is_trusted_ip !is_whitelisted
|
||||||
|
|
||||||
|
# --- WordPress wp-login.php "must-load-the-form-first" cookie challenge ---
|
||||||
|
# Defeats DISTRIBUTED credential-stuffing (hundreds of thousands of unique
|
||||||
|
# IPs, each low-and-slow, so the per-IP rule above can't see them). Such
|
||||||
|
# bots POST straight to /wp-login.php without ever GETting the form — on
|
||||||
|
# these sites the login POST:GET ratio is ~15:1. We hand out a cookie when
|
||||||
|
# the form is actually fetched (GET) and require it on POST; direct-POST
|
||||||
|
# bots lack it and are denied AT THE EDGE before reaching PHP. Real logins
|
||||||
|
# are unaffected — WordPress login already requires loading the page and
|
||||||
|
# accepting cookies. Immediate deny (NOT tarpit) — under a 300k-POST flood,
|
||||||
|
# holding tarpit connections would exhaust HAProxy. Honors the whitelist.
|
||||||
|
# Mark login-form GETs at REQUEST time (method/path are reliably evaluable
|
||||||
|
# here; in the response phase they are not) so the cookie is emitted on the
|
||||||
|
# form's own response.
|
||||||
|
http-request set-var(txn.wp_login_form) int(1) if METH_GET wp_login_path
|
||||||
|
http-after-response add-header set-cookie "whplc=1; Path=/; Max-Age=1800; HttpOnly; Secure; SameSite=Lax" if { var(txn.wp_login_form) -m found }
|
||||||
|
acl has_login_cookie req.cook(whplc) -m found
|
||||||
|
http-request deny deny_status 403 if METH_POST wp_login_path !has_login_cookie !is_local !is_trusted_ip !is_whitelisted
|
||||||
|
|
||||||
|
# WordPress REST batch endpoint lockdown ("wp2shell": CVE-2026-63030 +
|
||||||
|
# CVE-2026-60137). Chaining a core SQL injection with REST batch-route
|
||||||
|
# confusion gives unauthenticated RCE on WP 6.9.0-6.9.4 and 7.0.0-7.0.1
|
||||||
|
# (fixed in 6.9.5 / 7.0.2). Exploits are public and were used against this
|
||||||
|
# fleet on 2026-07-19/20; one site was compromised via this path before
|
||||||
|
# patching. This is a virtual patch: it does not repair the vulnerable
|
||||||
|
# application logic, it only removes reachability, so it stays until every
|
||||||
|
# site is confirmed on a fixed release.
|
||||||
|
#
|
||||||
|
# Both routing forms must be covered -- a rule matching only the pretty
|
||||||
|
# permalink path leaves the ?rest_route= fallback wide open, and urlp()
|
||||||
|
# does not URL-decode, hence the third ACL for the %2F spelling.
|
||||||
|
#
|
||||||
|
# Anonymous-only. batch/v1 is used legitimately by the block editor for
|
||||||
|
# multi-entity saves, so a blanket deny would break wp-admin for real
|
||||||
|
# users; requiring a wordpress_logged_in_* cookie costs them nothing.
|
||||||
|
# req.cook() needs an exact name and WordPress suffixes a per-site hash,
|
||||||
|
# so this substring-matches the raw Cookie header instead.
|
||||||
|
#
|
||||||
|
# Immediate deny, not tarpit -- holding connections open helps an attacker
|
||||||
|
# who is already scripting this. Honors the same whitelist as above.
|
||||||
|
acl wp_batch_path path_beg /wp-json/batch/v1
|
||||||
|
acl wp_batch_route urlp(rest_route) -i -m beg /batch/v1
|
||||||
|
acl wp_batch_route_enc query -i -m sub rest_route=%2Fbatch%2Fv1
|
||||||
|
acl has_wp_logged_in req.hdr(Cookie) -i -m sub wordpress_logged_in_
|
||||||
|
http-request deny deny_status 403 if wp_batch_path !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted
|
||||||
|
http-request deny deny_status 403 if wp_batch_route !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted
|
||||||
|
http-request deny deny_status 403 if wp_batch_route_enc !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted
|
||||||
|
|
||||||
|
# IP blocking using map file (manual blocks only)
|
||||||
|
# Map file format: /etc/haproxy/blocked_ips.map contains "<ip_or_cidr> 1" per line
|
||||||
|
# Runtime updates: echo "add map #0 IP_ADDRESS 1" | socat stdio /var/run/haproxy.sock
|
||||||
|
# Checks the real client IP (from headers if present, otherwise src)
|
||||||
|
# map_ip() converter supports both single IPs and CIDR ranges (e.g., 192.168.1.0/24)
|
||||||
|
acl is_blocked_ip var(txn.real_ip),map_ip(/etc/haproxy/blocked_ips.map,0) -m int gt 0
|
||||||
|
http-request set-path /blocked-ip if is_blocked_ip
|
||||||
|
use_backend default-backend if is_blocked_ip
|
||||||
|
{%- if suspension_enabled %}
|
||||||
|
|
||||||
|
# Site suspension routing. Any Host header listed in
|
||||||
|
# /etc/haproxy/suspended_domains.list is rewritten to /suspended and
|
||||||
|
# routed through default-backend, which is the same Flask app that
|
||||||
|
# serves the default page and blocked-ip page (port 8080 inside this
|
||||||
|
# container). The `/suspended` route returns HTTP 503 with a static
|
||||||
|
# suspension page. External tooling (e.g. WHP's site_disable.php)
|
||||||
|
# maintains the list file via `docker cp`. An empty list is safe —
|
||||||
|
# the ACL simply doesn't match. Sits after IP-blocking so 429/403
|
||||||
|
# still trigger first.
|
||||||
|
acl is_suspended_domain hdr(host),lower -f /etc/haproxy/suspended_domains.list
|
||||||
|
http-request set-path /suspended if is_suspended_domain
|
||||||
|
use_backend default-backend if is_suspended_domain
|
||||||
|
{%- endif %}
|
||||||
|
{%- if coraza_spoe_backend %}
|
||||||
|
|
||||||
|
# Coraza WAF inspection via SPOE. Runs AFTER rate-limit and IP-block
|
||||||
|
# guards (no point asking the WAF about requests we're already dropping)
|
||||||
|
# and AFTER the real-client-IP resolution (so Coraza sees the right src).
|
||||||
|
filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg
|
||||||
|
http-request send-spoe-group coraza coraza-req
|
||||||
|
|
||||||
|
# Enforce Coraza's verdict. The SPOA sets var(txn.coraza.action) to
|
||||||
|
# "deny" / "drop" / "redirect" when a rule with the corresponding
|
||||||
|
# disruptive action fires (depends on SecRuleEngine mode + per-rule
|
||||||
|
# ctl:ruleEngine overrides). Without these rules, Coraza would inspect
|
||||||
|
# but never block.
|
||||||
|
#
|
||||||
|
# On request-phase deny we return a rendered HTML page that surfaces the
|
||||||
|
# request reference (the unique-id) so a customer who's been blocked
|
||||||
|
# incorrectly can open a support ticket and quote it. lf-file expands
|
||||||
|
# log-format expressions inside the file at response time, so
|
||||||
|
# %[unique-id] / %[req.hdr(host)] / etc. get substituted live.
|
||||||
|
# Response-phase deny stays as a bare 403 — outbound blocks are rare in
|
||||||
|
# our config (Coraza response inspection is disabled by default) and
|
||||||
|
# an HTML body on a 403 generated mid-response could land mid-stream.
|
||||||
|
http-request return status 403 content-type "text/html; charset=utf-8" hdr waf-block "request" hdr x-request-reference "%[unique-id]" lf-file /haproxy/errors/403-waf.html if { var(txn.coraza.action) -m str deny }
|
||||||
|
http-response deny deny_status 403 hdr waf-block "response" hdr x-request-reference "%[unique-id]" if { var(txn.coraza.action) -m str deny }
|
||||||
|
http-request silent-drop if { var(txn.coraza.action) -m str drop }
|
||||||
|
http-response silent-drop if { var(txn.coraza.action) -m str drop }
|
||||||
|
http-request redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect }
|
||||||
|
http-response redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect }
|
||||||
|
|
||||||
|
# FAIL-OPEN on SPOA error. Upstream's example does the opposite — denies
|
||||||
|
# 500 if var(txn.coraza.error) is set — but for a hosting platform we'd
|
||||||
|
# rather lose WAF coverage briefly than 503 customer sites. The error
|
||||||
|
# variable still gets set, so monitoring can observe it.
|
||||||
|
{%- endif %}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# HAProxy Stats & Monitoring
|
||||||
|
frontend stats
|
||||||
|
bind 127.0.0.1:8404
|
||||||
|
stats enable
|
||||||
|
stats uri /stats
|
||||||
|
stats refresh 30s
|
||||||
|
stats show-legends
|
||||||
|
stats show-node
|
||||||
|
|
||||||
|
# Dedicated stick-table for WordPress wp-login.php brute-force tracking.
|
||||||
|
# Tracked via track-sc1 from the `web` frontend (hap_listener.tpl); counts only
|
||||||
|
# login POSTs per real client IP over a 60s window. Separate from the generic
|
||||||
|
# sc0 connection/rate table so the login-attempt threshold is independent of
|
||||||
|
# the (much higher) flood thresholds.
|
||||||
|
backend wp_bruteforce
|
||||||
|
stick-table type ip size 100k expire 30m store http_req_rate(60s)
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
|
|
||||||
#Subdomain method {{ domain }}
|
#Subdomain method {{ domain }}
|
||||||
acl {{ domain }}-acl hdr(host) -i {{ domain }}
|
acl {{ name }}-acl hdr(host) -i {{ domain }}
|
||||||
use_backend {{ name }}-backend if {{ domain }}-acl
|
|
||||||
|
# Detect Server-Sent Events (SSE) connections for {{ domain }}
|
||||||
|
# SSE uses Accept: text/event-stream or ?action=stream query parameter
|
||||||
|
acl {{ name }}-is-sse hdr(accept) -i -m sub text/event-stream
|
||||||
|
acl {{ name }}-is-sse-url urlp(action) -i -m str stream
|
||||||
|
|
||||||
|
# Route SSE traffic to SSE-optimized backend, regular traffic to standard backend
|
||||||
|
use_backend {{ name }}-sse-backend if {{ name }}-acl {{ name }}-is-sse
|
||||||
|
use_backend {{ name }}-sse-backend if {{ name }}-acl {{ name }}-is-sse-url
|
||||||
|
use_backend {{ name }}-backend if {{ name }}-acl
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
#Wildcard method {{ domain }}
|
||||||
|
acl {{ name }}-acl hdr_end(host) -i .{{ base_domain }}
|
||||||
|
use_backend {{ name }}-backend if {{ name }}-acl
|
||||||
+20
-2
@@ -346,16 +346,34 @@
|
|||||||
|
|
||||||
function loadDomains() {
|
function loadDomains() {
|
||||||
fetch('/api/domains')
|
fetch('/api/domains')
|
||||||
.then(response => response.json())
|
.then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
.then(domains => {
|
.then(domains => {
|
||||||
const domainList = document.getElementById('domainList');
|
const domainList = document.getElementById('domainList');
|
||||||
domainList.innerHTML = '';
|
domainList.innerHTML = '';
|
||||||
|
|
||||||
|
// Ensure domains is an array
|
||||||
|
if (!Array.isArray(domains)) {
|
||||||
|
console.error('Expected array of domains, got:', typeof domains, domains);
|
||||||
|
showStatus('Error: Invalid response format from server', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domains.length === 0) {
|
||||||
|
domainList.innerHTML = '<div class="domain-list-item"><p>No domains configured yet. Add your first domain above.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
domains.forEach(domain => {
|
domains.forEach(domain => {
|
||||||
const domainDiv = document.createElement('div');
|
const domainDiv = document.createElement('div');
|
||||||
domainDiv.className = 'domain-list-item';
|
domainDiv.className = 'domain-list-item';
|
||||||
domainDiv.innerHTML = `
|
domainDiv.innerHTML = `
|
||||||
<h3>${domain.domain}</h3>
|
<h3>${domain.domain}</h3>
|
||||||
<p>Backend: ${domain.backend_name}</p>
|
<p>Backend: ${domain.backend_name || 'Not configured'}</p>
|
||||||
<p>SSL: ${domain.ssl_enabled ? 'Enabled' : 'Disabled'}</p>
|
<p>SSL: ${domain.ssl_enabled ? 'Enabled' : 'Disabled'}</p>
|
||||||
<button onclick="requestSSL('${domain.domain}')" class="ssl-btn">
|
<button onclick="requestSSL('${domain.domain}')" class="ssl-btn">
|
||||||
${domain.ssl_enabled ? 'Renew SSL' : 'Enable SSL'}
|
${domain.ssl_enabled ? 'Renew SSL' : 'Enable SSL'}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>Site temporarily unavailable</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
padding: 50px 20px;
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
|
||||||
|
max-width: 560px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #f1f5f9;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 1.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #cbd5e1;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.05em;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>This site is temporarily unavailable.</h1>
|
||||||
|
<p>The site you are trying to reach is currently offline.</p>
|
||||||
|
<p class="note">Site owners: please contact support to restore service.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Source-IP whitelist — exempt from HAProxy rate limits (one IP or CIDR per line).
|
||||||
|
# Referenced by templates/hap_listener.tpl:
|
||||||
|
# acl is_trusted_ip src -f /etc/haproxy/trusted_ips.list
|
||||||
|
#
|
||||||
|
# Add trusted source IPs below. Do NOT commit real/personal IPs to this repo —
|
||||||
|
# it is mirrored publicly. Keep real entries in an untracked local copy, or add
|
||||||
|
# them directly on the server (the file lives in the /etc/haproxy named volume
|
||||||
|
# and persists across container recreates).
|
||||||
|
127.0.0.1
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Real-IP whitelist for proxy-header matching — exempt from HAProxy rate limits.
|
||||||
|
# Format: "<IP> 1" (one per line). Referenced by templates/hap_listener.tpl:
|
||||||
|
# acl is_whitelisted var(txn.real_ip),map_ip(/etc/haproxy/trusted_ips.map,0) -m int gt 0
|
||||||
|
#
|
||||||
|
# Add trusted real IPs below. Do NOT commit real/personal IPs to this repo —
|
||||||
|
# it is mirrored publicly. Keep real entries in an untracked local copy, or add
|
||||||
|
# them directly on the server (the file lives in the /etc/haproxy named volume
|
||||||
|
# and persists across container recreates).
|
||||||
|
127.0.0.1 1
|
||||||
Reference in New Issue
Block a user