Files
Mustafa ULUKAYA bb774141d4 fix(acme): make the challenge backend fixable from the panel
Correcting a wrong ACME challenge backend was impossible without a shell, and
even with one the correction did not reach the nodes.

The mint gate only fired when `acme_enabled` flipped. `acme_backend_url` was
written to the DB and minted nothing, so Apply answered "No pending changes to
apply" and the nodes kept the old address forever. It is now decided by
comparing the rendered `server _acme_mgmt` line against the active version —
the one line that answers "would the nodes talk to a different address?".
Comparing whole configs would flag every unrelated pending edit.

The field had no UI at all. Added to the cluster form with validation that
mirrors the backend rules, and keyed on `model_fields_set` so clearing it
reverts to the global setting — with a plain `is not None` test an empty box is
indistinguishable from "not submitted", so a value could never be removed.

Validation is asymmetric on purpose (utils/acme_backend_url):

- at the write boundary, reject what cannot express a reachable target —
  including the two silent traps: a scheme-less value became `localhost`, and
  an out-of-range port raised inside the generator and destroyed the config
- at render time, never reject. The shipped defaults are themselves loopback,
  so refusing to render would make every acme_enabled cluster unappliable,
  including for changes unrelated to ACME. Problems are logged and surfaced.

The port-less default stays 8080 rather than moving to HTTP's 80: the bundled
compose publishes nginx on 8080, so installs relying on it work today and the
first sign of breaking them would be the unattended renewal loop months later.
The omission is warned about instead.

RFC1918 is allowed and is usually the right answer here, and no DNS resolution
is performed — both deliberate departures from utils/ssrf_guard, whose policy
is the opposite of what this address needs. What the management host can
resolve says nothing about what the HAProxy node can reach.

Diagnostics stop reporting success on a dead path:

- check_port80 uses GET instead of HEAD and classifies the body. A proxy that
  has lost its /.well-known/acme-challenge/ location serves its SPA with HTTP
  200, which `status in (200, 404)` accepted as healthy. Warnings also surface
  when other domains pass, which previously hid the most diagnostic outcome.
- check_routing filters `mode`, joins `acme_enabled` and reads the APPLIED
  config instead of counting database rows, and reports a loopback target.
- every new condition is `warn`, never `fail`: the site wizard blocks submit on
  any fail, so a new failing condition would lock every install on upgrade day.

Also: normalise `frontends.mode` once per frontend. It is nullable, and the
raw value was interpolated into `mode {}`, emitting a literal `mode None` that
HAProxy rejects — taking down the whole cluster config. The ACME gate and the
backend-mode check now read the same normalised value.

And stop hardcoding PUBLIC_URL / MANAGEMENT_BASE_URL in docker-compose, which
silently ignored the operator's .env and made the wrong default load-bearing.
2026-08-11 17:15:55 +03:00

177 lines
5.5 KiB
YAML

services:
# PostgreSQL Database
postgres:
image: postgres:15-alpine
container_name: haproxy-openmanager-db
environment:
POSTGRES_DB: haproxy_openmanager
POSTGRES_USER: haproxy_user
POSTGRES_PASSWORD: haproxy_pass
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- haproxy-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U haproxy_user -d haproxy_openmanager"]
interval: 10s
timeout: 5s
retries: 5
# Redis Cache
redis:
image: redis:8.8.0-alpine
container_name: haproxy-openmanager-redis
command: redis-server --maxmemory 2gb --maxmemory-policy volatile-lru --save ""
ports:
- "6379:6379"
networks:
- haproxy-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Backend API - pulls from Docker Hub by default
# To build locally instead, uncomment the build section and run: docker compose build backend
backend:
image: taylanbakircioglu/haproxy-openmanager-backend:latest
# build:
# context: ./backend
# dockerfile: Dockerfile
container_name: haproxy-openmanager-backend
environment:
- DATABASE_URL=postgresql://haproxy_user:haproxy_pass@postgres:5432/haproxy_openmanager
- REDIS_URL=redis://redis:6379
- SECRET_KEY=your-secret-key-change-this-in-production
- DEBUG=False
- LOG_LEVEL=INFO
# Interpolated, not hardcoded: these were literals, so a value set in the
# host environment or .env was silently ignored and every install kept the
# localhost default. That default is also what the ACME challenge backend
# falls back to, and HAProxy resolves it ON THE HAPROXY NODE — so on any
# deployment where HAProxy is not this machine, it points at the wrong box.
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:8080}
- MANAGEMENT_BASE_URL=${MANAGEMENT_BASE_URL:-http://localhost:8080}
# Empty when unset on the host: the image CMD then falls back to
# WEB_CONCURRENCY (uvicorn's native env) and finally to 1.
- UVICORN_WORKERS=${UVICORN_WORKERS:-}
volumes:
- haproxy_configs:/etc/haproxy
# NOT published to the host: the API is reachable only through the nginx
# service (host :8080). Host port 8000 is therefore NOT this API — on a box
# running Portainer it is Portainer's edge tunnel, which answers 404 and looks
# deceptively like a working challenge endpoint.
expose:
- "8000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- haproxy-network
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Frontend React App - pulls from Docker Hub by default
# To build locally instead, uncomment the build section and run: docker compose build frontend
#
# NOTE: REACT_APP_* env vars are BUILD-time only for Create-React-App. The
# runtime container (serve -s build) does NOT consume them. The frontend
# uses same-origin (window.location) for /api/* and is routed by the nginx
# service below to the backend container. No env vars are required here.
frontend:
image: taylanbakircioglu/haproxy-openmanager-frontend:latest
# build:
# context: ./frontend
# dockerfile: Dockerfile
container_name: haproxy-openmanager-frontend
expose:
- "3000"
depends_on:
- backend
networks:
- haproxy-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
# Nginx Reverse Proxy
nginx:
# Pinned to a patched release for the nginx "poolslip" advisory
# (mainline <=1.31.0 affected; fixed in mainline 1.31.1+ / stable 1.30.2+).
image: nginx:1.31.1-alpine
container_name: haproxy-openmanager-nginx
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "8080:8080"
depends_on:
- frontend
- backend
networks:
- haproxy-network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
# HAProxy Instance (for testing)
haproxy:
image: haproxy:2.8-alpine
container_name: haproxy-instance
volumes:
- ./haproxy/haproxy-simple.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
ports:
- "80:80"
- "8404:8404"
networks:
- haproxy-network
# Remote HAProxy Instance 1 (Production)
haproxy-remote1:
image: haproxy:2.8-alpine
container_name: haproxy-remote1
volumes:
- ./haproxy/haproxy-simple.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
ports:
- "8001:80"
- "8405:8404"
networks:
- haproxy-network
environment:
- HAPROXY_ENV=production
# Remote HAProxy Instance 2 (Staging)
haproxy-remote2:
image: haproxy:2.8-alpine
container_name: haproxy-remote2
volumes:
- ./haproxy/haproxy-simple.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
ports:
- "8002:80"
- "8406:8404"
networks:
- haproxy-network
environment:
- HAPROXY_ENV=staging
volumes:
postgres_data:
haproxy_configs:
networks:
haproxy-network:
driver: bridge