mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-21 01:53:24 +00:00
bb774141d4
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.
190 lines
7.6 KiB
Python
190 lines
7.6 KiB
Python
from fastapi import APIRouter, HTTPException, Header
|
|
from pydantic import BaseModel
|
|
from typing import Dict, Any
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from database.connection import get_database_connection, close_database_connection
|
|
from utils.acme_backend_url import AcmeBackendUrlError, validate_acme_backend_url
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/settings", tags=["Settings"])
|
|
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
settings: Dict[str, Any]
|
|
|
|
|
|
async def _get_admin_user(authorization: str):
|
|
from auth_middleware import get_current_user_from_token
|
|
current_user = await get_current_user_from_token(authorization)
|
|
if not current_user.get('is_admin', False):
|
|
raise HTTPException(status_code=403, detail="Admin access required for settings management")
|
|
return current_user
|
|
|
|
|
|
@router.get("/{category}")
|
|
async def get_settings_by_category(category: str, authorization: str = Header(None)):
|
|
current_user = await _get_admin_user(authorization)
|
|
conn = await get_database_connection()
|
|
try:
|
|
rows = await conn.fetch(
|
|
"SELECT key, value, description, updated_at FROM system_settings WHERE category = $1 ORDER BY key",
|
|
category
|
|
)
|
|
result = {}
|
|
for row in rows:
|
|
key_suffix = row['key'].split('.', 1)[1] if '.' in row['key'] else row['key']
|
|
result[key_suffix] = {
|
|
"value": row['value'],
|
|
"description": row['description'],
|
|
"updated_at": row['updated_at'].isoformat() if row['updated_at'] else None
|
|
}
|
|
return {"category": category, "settings": result}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error fetching settings for category '{category}': {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to fetch settings")
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
def _validate_acme_challenge_backend_url(value):
|
|
"""Validate `acme.challenge_backend_url` exactly as the config renderer reads it.
|
|
|
|
Settings values are stored as jsonb, so the renderer json.loads them before use
|
|
(services/haproxy_config.py). Validating the raw column text instead of the
|
|
decoded string would check the quoting rather than the URL.
|
|
"""
|
|
decoded = value
|
|
if isinstance(decoded, str):
|
|
try:
|
|
decoded = json.loads(decoded)
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
if decoded is None:
|
|
return
|
|
try:
|
|
validate_acme_backend_url(str(decoded))
|
|
except AcmeBackendUrlError as exc:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"acme.challenge_backend_url: {exc}",
|
|
) from None
|
|
|
|
|
|
# Per-key validators for settings that end up in generated configuration or in
|
|
# outbound requests. Everything else is still written through unchecked; this is a
|
|
# deliberate allow-list of the keys where a bad value causes silent breakage rather
|
|
# than an obvious one.
|
|
_SETTING_VALIDATORS = {
|
|
"acme.challenge_backend_url": _validate_acme_challenge_backend_url,
|
|
}
|
|
|
|
|
|
@router.put("/{category}")
|
|
async def update_settings_by_category(
|
|
category: str,
|
|
body: SettingsUpdate,
|
|
authorization: str = Header(None)
|
|
):
|
|
current_user = await _get_admin_user(authorization)
|
|
conn = await get_database_connection()
|
|
try:
|
|
# Validate the whole batch before writing any of it, so a rejected key cannot
|
|
# leave the category half-applied.
|
|
for key_suffix, value in body.settings.items():
|
|
validator = _SETTING_VALIDATORS.get(f"{category}.{key_suffix}")
|
|
if validator is not None:
|
|
validator(value)
|
|
|
|
updated = []
|
|
for key_suffix, value in body.settings.items():
|
|
full_key = f"{category}.{key_suffix}"
|
|
result = await conn.execute("""
|
|
INSERT INTO system_settings (key, value, category, updated_at, updated_by)
|
|
VALUES ($1, $2::jsonb, $3, $4, $5)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
value = $2::jsonb,
|
|
updated_at = $4,
|
|
updated_by = $5
|
|
""", full_key, str(value) if not isinstance(value, str) else value,
|
|
category, datetime.utcnow(), current_user.get('id'))
|
|
updated.append(full_key)
|
|
|
|
logger.info(f"Settings updated by user {current_user.get('username')}: {updated}")
|
|
return {"message": f"Updated {len(updated)} settings", "keys": updated}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error updating settings for category '{category}': {e}")
|
|
raise HTTPException(status_code=500, detail="Failed to update settings")
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
|
|
@router.get("/acme/test-connection")
|
|
async def test_acme_connection(authorization: str = Header(None), directory_url: str = None):
|
|
current_user = await _get_admin_user(authorization)
|
|
|
|
if not directory_url:
|
|
conn = await get_database_connection()
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"SELECT value FROM system_settings WHERE key = 'acme.directory_url'"
|
|
)
|
|
if not row or not row['value']:
|
|
return {"success": False, "error": "No ACME directory URL configured"}
|
|
|
|
import json as _json
|
|
directory_url = _json.loads(row['value']) if isinstance(row['value'], str) else row['value']
|
|
if isinstance(directory_url, dict):
|
|
directory_url = directory_url.get('value', directory_url)
|
|
finally:
|
|
await close_database_connection(conn)
|
|
|
|
# SECURITY (GHSA-3vh4-gvxx-wm2p): validate the URL before any outbound request
|
|
# (https-only; block loopback/RFC1918/link-local/cloud-metadata after DNS),
|
|
# pin the connector to IPv4, and never follow redirects. Also do NOT reflect
|
|
# arbitrary upstream JSON keys back to the caller — that was an information-
|
|
# disclosure oracle. Only report presence of the FIXED, known ACME directory
|
|
# field names (never attacker-controlled data).
|
|
from utils.ssrf_guard import assert_public_url, safe_connector, SSRFValidationError
|
|
|
|
directory_url = str(directory_url)
|
|
try:
|
|
await assert_public_url(directory_url)
|
|
except SSRFValidationError as e:
|
|
return {"success": False, "error": f"Refused to fetch directory URL: {e}"}
|
|
|
|
_KNOWN_ACME_FIELDS = ["newNonce", "newAccount", "newOrder", "newAuthz", "revokeCert", "keyChange"]
|
|
try:
|
|
import aiohttp
|
|
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
|
async with session.get(
|
|
directory_url,
|
|
timeout=aiohttp.ClientTimeout(total=10),
|
|
allow_redirects=False,
|
|
) as resp:
|
|
if resp.status == 200:
|
|
data = await resp.json(content_type=None)
|
|
if not isinstance(data, dict):
|
|
return {"success": False, "error": "Directory URL did not return a JSON object"}
|
|
present = [k for k in _KNOWN_ACME_FIELDS if k in data]
|
|
if not present:
|
|
return {"success": False, "error": "Response is not a valid ACME directory"}
|
|
return {
|
|
"success": True,
|
|
"directory": directory_url,
|
|
"endpoints": present,
|
|
}
|
|
else:
|
|
return {"success": False, "error": f"HTTP {resp.status} from directory URL"}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|