mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-04 12:31:31 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 882d25bb68 | |||
| 0ebf6583ea | |||
| 6be19f0bb5 | |||
| 9e5185c458 | |||
| 520b69a1c6 | |||
| 56107fa86f | |||
| f86a4331e8 | |||
| 1c47e246ec | |||
| d914f2398b | |||
| 9c1f3c811b | |||
| c79391cd13 | |||
| 9e2ea04777 |
@@ -1,4 +1,4 @@
|
||||
from fastapi import HTTPException, status
|
||||
from fastapi import HTTPException, status, Header
|
||||
from typing import Optional, Dict, Any
|
||||
from jose import jwt
|
||||
import logging
|
||||
@@ -92,6 +92,18 @@ async def get_current_user_from_token(authorization: Optional[str] = None) -> Op
|
||||
detail="Authentication failed"
|
||||
)
|
||||
|
||||
async def require_authenticated_user(authorization: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||
"""FastAPI dependency: require a valid operator JWT, else 401.
|
||||
|
||||
Reads the Authorization header itself, so it can be attached at router or
|
||||
route level to gate operator/UI endpoints that must not be public:
|
||||
APIRouter(..., dependencies=[Depends(require_authenticated_user)])
|
||||
@router.get(..., dependencies=[Depends(require_authenticated_user)])
|
||||
Any authenticated user passes (no fine-grained RBAC here) — this restores the
|
||||
pre-existing "logged-in users only" expectation without changing role access.
|
||||
"""
|
||||
return await get_current_user_from_token(authorization)
|
||||
|
||||
async def get_current_user_from_token_no_exception(authorization: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get current user from JWT token without raising HTTPException.
|
||||
|
||||
@@ -194,7 +194,14 @@ async def ensure_agents_table():
|
||||
'use_backend_rules': "ALTER TABLE frontends ADD COLUMN use_backend_rules JSONB DEFAULT '[]'::jsonb;",
|
||||
'request_headers': "ALTER TABLE frontends ADD COLUMN request_headers TEXT;",
|
||||
'response_headers': "ALTER TABLE frontends ADD COLUMN response_headers TEXT;",
|
||||
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;"
|
||||
'maxconn': "ALTER TABLE frontends ADD COLUMN maxconn INTEGER;",
|
||||
# Issue #38: SPOE filter directives (e.g. Coraza WAF) and frontend
|
||||
# log-format were silently dropped on bulk-import / manual edit
|
||||
# because the parser recognised only a fixed set of directives.
|
||||
# These nullable TEXT columns persist them verbatim (multi-line for
|
||||
# `filters`), mirroring the request_headers/options passthrough.
|
||||
'log_format': "ALTER TABLE frontends ADD COLUMN log_format TEXT;",
|
||||
'filters': "ALTER TABLE frontends ADD COLUMN filters TEXT;"
|
||||
}
|
||||
|
||||
for col, query in frontend_columns.items():
|
||||
@@ -1741,7 +1748,12 @@ async def ensure_agent_activity_logs_table():
|
||||
# columns on letsencrypt_accounts/letsencrypt_orders/acme_challenges and the brand-new
|
||||
# letsencrypt_account_dns_credentials table (ensure_letsencrypt_dns_credentials step).
|
||||
# All additive + idempotent; default challenge_type 'http-01' keeps existing flows byte-identical.
|
||||
SCHEMA_VERSION = 8
|
||||
# v1.8.8 (Issue #38 — SPOE filter + frontend log-format): bumped 8 -> 9 for the additive
|
||||
# `log_format` + `filters` TEXT columns on `frontends` (frontend_columns loop). Without this
|
||||
# bump, already-deployed databases (version >= 8) skip the whole migration run and never gain
|
||||
# the columns, so the frontends SELECT/INSERT would fail. Additive + idempotent + nullable;
|
||||
# existing rows stay NULL and render byte-identical.
|
||||
SCHEMA_VERSION = 9
|
||||
|
||||
|
||||
async def run_all_migrations():
|
||||
|
||||
+28
-45
@@ -85,6 +85,11 @@ class FrontendConfig(BaseModel):
|
||||
response_headers: Optional[str] = None
|
||||
options: Optional[str] = None
|
||||
tcp_request_rules: Optional[str] = None
|
||||
# Issue #38: SPOE filter directives (Coraza WAF etc.) + frontend log-format.
|
||||
# Passthrough TEXT (no validator) — SPOE `filter ... config <path>` legitimately
|
||||
# references an operator-managed file, so the ACL `-f` guard must NOT apply here.
|
||||
log_format: Optional[str] = None
|
||||
filters: Optional[str] = None
|
||||
timeout_client: Optional[int] = None
|
||||
timeout_http_request: Optional[int] = None
|
||||
rate_limit: Optional[int] = None
|
||||
@@ -451,26 +456,17 @@ class FrontendConfig(BaseModel):
|
||||
if any(dangerous in rule.lower() for dangerous in ['$(', '`']):
|
||||
raise ValueError(f'ACL rule contains potentially dangerous content: "{rule}"')
|
||||
|
||||
# Phase K Phase D follow-up (Bulgu #12 round 3) — reject
|
||||
# the HAProxy `-f <file>` pattern-file flag here too so the
|
||||
# manual Frontend API mirrors the wizard's parity rule.
|
||||
# HAProxy OpenManager does not provision pattern files
|
||||
# onto the HAProxy node filesystem, so any `-f /path/...`
|
||||
# reference will fail HAProxy's `-c` parse at apply time
|
||||
# with "failed to open pattern file". Reject up-front so
|
||||
# operators get the same actionable error from both the
|
||||
# manual page and the wizard.
|
||||
if re.search(r"(^|\s)-f(\s|$)", rule):
|
||||
raise ValueError(
|
||||
f'ACL rule "{rule}" uses the HAProxy `-f <file>` '
|
||||
"pattern-file flag, which is not supported in "
|
||||
"HAProxy OpenManager: the product does not "
|
||||
"provision pattern files onto the HAProxy node "
|
||||
"filesystem, so the reference would fail at "
|
||||
"reload time. Use inline values instead "
|
||||
"(e.g. `src 10.0.0.0/24` rather than "
|
||||
"`src -f /etc/haproxy/admins.lst`)."
|
||||
)
|
||||
# Issue #38 follow-up — the `-f <file>` pattern-file flag
|
||||
# is ACCEPTED here (the Bulgu #12 hard reject was removed).
|
||||
# Pattern files are operator-managed host files, exactly
|
||||
# like the SPOE `filter ... config <path>` reference this
|
||||
# release started preserving: bulk import always accepted
|
||||
# `-f`, the free-form fields (request_headers,
|
||||
# tcp_request_rules) always accepted it, and the agent
|
||||
# runs `haproxy -c` before every reload so a missing file
|
||||
# fails safely (previous config keeps running). The route
|
||||
# handlers surface a non-blocking warning listing the
|
||||
# referenced pattern files instead.
|
||||
|
||||
validated_rules.append(rule)
|
||||
|
||||
@@ -510,20 +506,12 @@ class FrontendConfig(BaseModel):
|
||||
if not any(rule.startswith(redirect_type) for redirect_type in valid_redirects):
|
||||
raise ValueError(f'Invalid redirect rule: "{rule}". Must start with: location, prefix, or scheme.')
|
||||
|
||||
# Phase K Phase D follow-up (Bulgu #12 round 3) —
|
||||
# mirror the wizard's `-f <file>` guard here. The
|
||||
# `X !X` contradiction check used to live alongside
|
||||
# this guard, but Bulgu #62 (round-22 audit) moved
|
||||
# it into the route handler so updates can grandfather
|
||||
# legacy rules created before the contradiction guard
|
||||
# landed. See `routers/frontend.py::_collect_routing_rule_contradictions`.
|
||||
if re.search(r"(^|\s)-f(\s|$)", rule):
|
||||
raise ValueError(
|
||||
f'Redirect rule "{rule}" uses the HAProxy `-f <file>` '
|
||||
"pattern-file flag, which is not supported in "
|
||||
"HAProxy OpenManager: the product does not provision "
|
||||
"pattern files onto the HAProxy node filesystem."
|
||||
)
|
||||
# Issue #38 follow-up — `-f <file>` pattern-file references
|
||||
# are ACCEPTED (Bulgu #12 hard reject removed; see
|
||||
# validate_acl_rules for the full rationale). The `X !X`
|
||||
# contradiction check lives in the route handler
|
||||
# (`routers/frontend.py::_collect_routing_rule_contradictions`,
|
||||
# Bulgu #62) and is unchanged.
|
||||
|
||||
validated_rules.append(rule)
|
||||
|
||||
@@ -531,9 +519,12 @@ class FrontendConfig(BaseModel):
|
||||
|
||||
@validator('use_backend_rules')
|
||||
def validate_use_backend_rules_syntax(cls, v):
|
||||
"""Phase K Phase D follow-up (Bulgu #12 round 3) — manual
|
||||
Frontend API parity guard: reject `-f <file>` references
|
||||
and dangerous shell patterns.
|
||||
"""Manual Frontend API guard for dangerous shell patterns.
|
||||
|
||||
Issue #38 follow-up — the Bulgu #12 `-f <file>` hard reject
|
||||
was removed (see validate_acl_rules for the rationale);
|
||||
pattern-file references are operator-managed host files and
|
||||
are surfaced as non-blocking warnings by the route handlers.
|
||||
|
||||
Bulgu #62 (round-22 audit) — the `X !X` contradiction check
|
||||
previously lived here but moved into the route handler so
|
||||
@@ -563,13 +554,5 @@ class FrontendConfig(BaseModel):
|
||||
f'use_backend rule contains potentially dangerous '
|
||||
f'content: "{rule}"'
|
||||
)
|
||||
if re.search(r"(^|\s)-f(\s|$)", rule):
|
||||
raise ValueError(
|
||||
f'use_backend rule "{rule}" uses the HAProxy '
|
||||
"`-f <file>` pattern-file flag, which is not "
|
||||
"supported in HAProxy OpenManager: the product "
|
||||
"does not provision pattern files onto the HAProxy "
|
||||
"node filesystem."
|
||||
)
|
||||
validated_rules.append(rule)
|
||||
return validated_rules
|
||||
@@ -179,35 +179,17 @@ _MAX_RULE_STRING_LEN = 4096
|
||||
# attempts.
|
||||
_DANGEROUS_RULE_PATTERNS = ("$(", "`")
|
||||
|
||||
# Phase K Phase D follow-up (Bulgu #12 round 3) — the HAProxy `-f
|
||||
# <file>` ACL/condition flag instructs HAProxy to load match patterns
|
||||
# from a server-side file at parse time. HAProxy OpenManager is a
|
||||
# fully-managed product: we do NOT provision pattern files onto the
|
||||
# HAProxy node's filesystem, and operators have no UI to upload one.
|
||||
# A `-f /some/path` reference therefore ALWAYS resolves to
|
||||
# "file not found" when HAProxy's real `-c` parse runs at apply
|
||||
# time, producing exactly the operator-reported failure mode:
|
||||
# [ALERT] parsing ACL 'acl1' : failed to open pattern file </path>.
|
||||
# [ALERT] parsing switching rule : no such ACL : 'acl1'.
|
||||
#
|
||||
# Surface this BEFORE persist by rejecting `-f` in any rule string
|
||||
# that comes through the wizard / manual frontend API. Reject ALL
|
||||
# variants (` -f `, leading `-f `, trailing `... -f`) defensively so
|
||||
# operators cannot slip the flag through with creative spacing.
|
||||
# The check is anchored to ACL/condition rule strings only; raw
|
||||
# HAProxy snippet fields (tcp_request_rules, request_headers, ...)
|
||||
# are NOT touched because those are inherently free-form and
|
||||
# advanced operators may legitimately reference pre-provisioned
|
||||
# pattern files there.
|
||||
_ACL_FILE_FLAG_PATTERN = re.compile(r"(^|\s)-f(\s|$)")
|
||||
_ACL_FILE_FLAG_MESSAGE = (
|
||||
"pattern-file references with '-f <file>' are not supported in ACL / "
|
||||
"use_backend / redirect rules: HAProxy OpenManager does not provision "
|
||||
"pattern files onto the HAProxy node's filesystem, so the reference "
|
||||
"would always fail at HAProxy reload time. Use inline values "
|
||||
"instead (e.g. `acl is_admin src 10.0.0.0/24` rather than "
|
||||
"`acl is_admin src -f /etc/haproxy/admins.lst`)."
|
||||
)
|
||||
# Issue #38 follow-up — the HAProxy `-f <file>` ACL/condition flag
|
||||
# loads match patterns from a file on the HAProxy host. The Bulgu #12
|
||||
# hard reject (`_ACL_FILE_FLAG_PATTERN`/`_ACL_FILE_FLAG_MESSAGE`) was
|
||||
# removed: pattern files are operator-managed host files (exactly like
|
||||
# the SPOE `filter ... config <path>` reference preserved since
|
||||
# v1.8.8), bulk import and the free-form fields (tcp_request_rules,
|
||||
# request_headers) always accepted them, and the agent runs
|
||||
# `haproxy -c` before every reload so a missing file fails safely
|
||||
# (the previous config keeps running). The manual frontend route
|
||||
# handlers emit a non-blocking warning listing referenced pattern
|
||||
# files (`routers/frontend.py::_pattern_file_warnings`).
|
||||
|
||||
# Phase K Phase D follow-up (Bulgu #13) — detect a routing /
|
||||
# redirect rule whose condition references the SAME ACL in both
|
||||
@@ -305,13 +287,10 @@ def _validate_haproxy_directive_string(
|
||||
f"{field_label} entry contains potentially dangerous content: "
|
||||
f"{pattern!r}"
|
||||
)
|
||||
# Phase K Phase D follow-up (Bulgu #12 round 3) — reject the
|
||||
# HAProxy `-f <file>` pattern-file flag because OpenManager does
|
||||
# not manage the HAProxy node filesystem. See the module-level
|
||||
# `_ACL_FILE_FLAG_PATTERN` docstring for the full operator-
|
||||
# reported failure mode this guards against.
|
||||
if _ACL_FILE_FLAG_PATTERN.search(stripped):
|
||||
raise ValueError(f"{field_label}: {_ACL_FILE_FLAG_MESSAGE}")
|
||||
# Issue #38 follow-up — `-f <file>` pattern-file references are
|
||||
# ACCEPTED (Bulgu #12 hard reject removed; see the module-level
|
||||
# `_ACL_FILE_FLAG_PATTERN` comment). The route handlers surface
|
||||
# a non-blocking pattern-file warning instead.
|
||||
# Phase K Phase D follow-up (Bulgu #13) — for routing /
|
||||
# redirect rules (not ACL definitions themselves), reject a
|
||||
# condition that contains the same ACL in both positive and
|
||||
@@ -1083,21 +1062,12 @@ class FrontendStep(BaseModel):
|
||||
normalised: List[Union[str, dict]] = []
|
||||
for el in v:
|
||||
if isinstance(el, dict):
|
||||
# Phase K Phase D follow-up (Bulgu #12 round 3
|
||||
# extension) — dict-shaped redirect rules emit their
|
||||
# `condition` / `target` fields VERBATIM into the
|
||||
# rendered HAProxy directive. A dict with
|
||||
# `condition: "if { src -f /etc/haproxy/x.lst }"`
|
||||
# would slip past the string-only validator above
|
||||
# and trigger the same operator-reported "failed to
|
||||
# open pattern file" rejection at apply time. Reject
|
||||
# `-f` in any string-shaped value the dict carries.
|
||||
for field_name in ("condition", "target", "type"):
|
||||
val = el.get(field_name)
|
||||
if isinstance(val, str) and _ACL_FILE_FLAG_PATTERN.search(val):
|
||||
raise ValueError(
|
||||
f"redirect_rules.{field_name}: {_ACL_FILE_FLAG_MESSAGE}"
|
||||
)
|
||||
# Issue #38 follow-up — dict-shaped redirect rules may
|
||||
# carry `-f <file>` pattern-file references in their
|
||||
# `condition`/`target` values; these are ACCEPTED now
|
||||
# (Bulgu #12 hard reject removed — operator-managed
|
||||
# host files, fail-safe apply; see module-level
|
||||
# `_ACL_FILE_FLAG_PATTERN` comment).
|
||||
# Bulgu #13 extension — same contradiction guard
|
||||
# for dict-shaped redirect conditions.
|
||||
cond_val = el.get("condition")
|
||||
|
||||
+141
-84
@@ -251,7 +251,7 @@ def calculate_agent_health(status, last_seen):
|
||||
return "offline"
|
||||
|
||||
@router.get("", summary="Get All Agents", response_description="List of all agents")
|
||||
async def get_agents(pool_id: Optional[int] = None, authorization: str = Header(None)):
|
||||
async def get_agents(pool_id: Optional[int] = None, authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
|
||||
"""
|
||||
# Get All Agents
|
||||
|
||||
@@ -303,9 +303,22 @@ async def get_agents(pool_id: Optional[int] = None, authorization: str = Header(
|
||||
- **haproxy_status**: Status of HAProxy service on agent's server
|
||||
- **last_seen**: Last heartbeat timestamp
|
||||
"""
|
||||
# SECURITY (GHSA-3p5c-m5m4-mjpx): the agent inventory (names, hostnames, IPs,
|
||||
# pools, OS) is operator data and was previously served unauthenticated — it is
|
||||
# also the read-back channel used in the RCE exfil PoC. Require EITHER a valid
|
||||
# operator JWT OR a valid agent X-API-Key: deployed agents poll this endpoint
|
||||
# (with their key, not a JWT) to read their own applied_config_version and avoid
|
||||
# re-applying config on restart, so a JWT-only gate would break them. Checked
|
||||
# before the try so the 401 is not swallowed by the generic handler.
|
||||
if authorization:
|
||||
current_user = await get_current_user_from_token(authorization) # raises 401 on invalid JWT
|
||||
else:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
if not await validate_agent_api_key(x_api_key):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
try:
|
||||
if pool_id:
|
||||
agents = await conn.fetch("""
|
||||
@@ -763,6 +776,14 @@ async def generate_uninstall_script(platform: str, authorization: str = Header(N
|
||||
sudo ./uninstall-agent.sh
|
||||
```
|
||||
"""
|
||||
# SECURITY (GHSA-3p5c-m5m4-mjpx): require authentication (operator JWT or agent
|
||||
# key), consistent with generate-install-script. The uninstall script itself is
|
||||
# generic (no secrets/topology), but an agent-management endpoint should not be
|
||||
# anonymously reachable. Checked before the try so the 401 is not swallowed.
|
||||
if authorization:
|
||||
await get_current_user_from_token(authorization)
|
||||
elif not await validate_agent_api_key(x_api_key):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
# Normalize platform to a canonical key (always 'linux' or 'macos').
|
||||
# macOS agents register with platform 'darwin' (from `uname -s`), so the
|
||||
@@ -958,14 +979,24 @@ def _extract_agent_ip(heartbeat_data: AgentHeartbeat) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@router.post("/{agent_id}/heartbeat")
|
||||
async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat):
|
||||
async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat, x_api_key: Optional[str] = Header(None)):
|
||||
"""Receive agent heartbeat and update status."""
|
||||
# Agent authentication is MANDATORY (GHSA-3p5c-m5m4-mjpx). This legacy by-ID
|
||||
# heartbeat previously had NO auth, allowing unauthenticated state spoofing of
|
||||
# any agent row. Deployed agents use the by-name heartbeat; a valid global
|
||||
# agent token is now required here too. NOTE: raised BEFORE the try below so
|
||||
# the 401 is not swallowed by the generic `except Exception` handler.
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key on by-id heartbeat for agent ID {agent_id}")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
await conn.execute("""
|
||||
UPDATE agents
|
||||
SET status = 'online',
|
||||
UPDATE agents
|
||||
SET status = 'online',
|
||||
last_seen = CURRENT_TIMESTAMP,
|
||||
hostname = COALESCE($2, hostname),
|
||||
haproxy_status = COALESCE($3, haproxy_status),
|
||||
@@ -1088,6 +1119,8 @@ async def agent_config_applied_notification(agent_name: str, notification_data:
|
||||
await close_database_connection(conn)
|
||||
return {"status": "ok", "message": "Config applied notification received"}
|
||||
|
||||
except HTTPException:
|
||||
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process config applied notification from agent '{agent_name}': {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -1183,6 +1216,8 @@ async def agent_config_validation_failed(agent_name: str, notification_data: dic
|
||||
|
||||
return {"status": "ok", "message": "Validation error notification received"}
|
||||
|
||||
except HTTPException:
|
||||
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process validation error notification from agent '{agent_name}': {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -1472,6 +1507,8 @@ async def agent_config_sync(agent_name: str, sync_data: dict, x_api_key: Optiona
|
||||
logger.info(f"CONFIG SYNC: Agent '{agent_name}' synced {len(active_backends)} backends, {len(active_frontends)} frontends, {len(active_servers)} servers with database")
|
||||
return {"status": "ok", "message": f"Config synced - {len(active_backends)} backends, {len(active_frontends)} frontends, {len(active_servers)} servers processed"}
|
||||
|
||||
except HTTPException:
|
||||
raise # let auth 401/403 propagate (do not turn it into a 200 error body)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process config sync from agent '{agent_name}': {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -1532,21 +1569,25 @@ async def agent_heartbeat_by_name(
|
||||
logger.error(f"Unexpected error processing heartbeat: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
# Agent authentication is MANDATORY (GHSA-3p5c-m5m4-mjpx). A valid global agent
|
||||
# token is required to heartbeat OR auto-register. Deployed agents always send
|
||||
# X-API-Key; an absent/invalid key is an unauthenticated caller. This is done
|
||||
# OUTSIDE the processing try below (whose generic `except Exception` would
|
||||
# otherwise convert the 401 into a 500), and before opening a DB connection
|
||||
# (validate_agent_api_key(None) needs no DB). Closes keyless heartbeat spoofing
|
||||
# and keyless rogue-agent auto-registration (the `elif not agent` keyless path
|
||||
# below is now unreachable, since agent_auth is guaranteed truthy past here).
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key on heartbeat for agent '{heartbeat_data.name}'")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
# Continue with normal heartbeat processing
|
||||
try:
|
||||
# Validate agent API key for security
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
conn = await get_database_connection()
|
||||
agent_name = heartbeat_data.name
|
||||
|
||||
# If API key provided, validate it exists but allow placeholder agent updates
|
||||
if x_api_key and not agent_auth:
|
||||
await close_database_connection(conn)
|
||||
logger.warning(f"Invalid API key provided by agent '{agent_name}'")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
agent = await conn.fetchrow("SELECT id, pool_id, api_key FROM agents WHERE name = $1", agent_name)
|
||||
|
||||
# If agent exists and is using a different API key, update the token association
|
||||
@@ -1955,9 +1996,19 @@ async def agent_heartbeat_by_name(
|
||||
@router.get("/{agent_name}/config")
|
||||
async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
||||
"""Get HAProxy configuration for specific agent"""
|
||||
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). Checked BEFORE any
|
||||
# DB work and before the existence check, so an unauthenticated caller learns
|
||||
# neither the full haproxy.cfg nor whether the agent exists. Raised before the
|
||||
# try so it is not swallowed by the generic handler; validate_agent_api_key(None)
|
||||
# returns None without touching the DB.
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key for agent '{agent_name}' config fetch")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
# Get agent info first to check pool
|
||||
# CRITICAL: Include cluster's haproxy_bin_path, haproxy_config_path, stats_socket_path
|
||||
# These are needed for dynamic validation - cluster admin can change paths without reinstalling agent
|
||||
@@ -1969,30 +2020,19 @@ async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(No
|
||||
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE a.name = $1
|
||||
""", agent_name)
|
||||
|
||||
|
||||
if not agent_info:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||
|
||||
# Validate agent API key
|
||||
# API key is global - can be used for multiple agents
|
||||
if x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if not agent_auth:
|
||||
await close_database_connection(conn)
|
||||
logger.warning(f"Invalid API key provided for agent '{agent_name}' config fetch")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
# Log which agent's API key was used (for audit trail)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.info(f"Agent '{agent_name}' fetching config using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' fetching config using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
logger.debug(f"Config fetch authorized for agent '{agent_name}'")
|
||||
|
||||
|
||||
# Log which agent's API key was used (for audit trail)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.info(f"Agent '{agent_name}' fetching config using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' fetching config using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
logger.debug(f"Config fetch authorized for agent '{agent_name}'")
|
||||
|
||||
if not agent_info['enabled']:
|
||||
await close_database_connection(conn)
|
||||
return {
|
||||
@@ -2083,9 +2123,18 @@ async def get_agent_config(agent_name: str, x_api_key: Optional[str] = Header(No
|
||||
@router.get("/{agent_name}/ssl-certificates")
|
||||
async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = None, x_api_key: Optional[str] = Header(None)):
|
||||
"""Get SSL certificates for specific agent's cluster"""
|
||||
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). This response
|
||||
# returns SSL private_key_content, so authentication is checked BEFORE any DB
|
||||
# work and before the existence check. Raised before the try so the 401 is not
|
||||
# swallowed; validate_agent_api_key(None) returns None without a DB hit.
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key for agent '{agent_name}' SSL certificates")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
# Get agent and cluster info first
|
||||
agent_info = await conn.fetchrow("""
|
||||
SELECT a.id, a.name, a.pool_id, hc.id as cluster_id, hc.name as cluster_name,
|
||||
@@ -2094,29 +2143,18 @@ async def get_agent_ssl_certificates(agent_name: str, since: Optional[str] = Non
|
||||
LEFT JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
|
||||
WHERE a.name = $1
|
||||
""", agent_name)
|
||||
|
||||
|
||||
if not agent_info:
|
||||
await close_database_connection(conn)
|
||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
||||
|
||||
# Validate agent API key
|
||||
# API key is global - can be used for multiple agents
|
||||
if x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if not agent_auth:
|
||||
await close_database_connection(conn)
|
||||
logger.warning(f"Invalid API key provided for agent '{agent_name}' SSL certificates")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
# Log which agent's API key was used (for audit trail)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.info(f"Agent '{agent_name}' fetching SSL certificates using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' fetching SSL certificates using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
logger.debug(f"SSL fetch authorized for agent '{agent_name}'")
|
||||
|
||||
# Log which agent's API key was used (for audit trail)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.info(f"Agent '{agent_name}' fetching SSL certificates using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' fetching SSL certificates using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
logger.debug(f"SSL fetch authorized for agent '{agent_name}'")
|
||||
|
||||
if not agent_info['enabled']:
|
||||
await close_database_connection(conn)
|
||||
@@ -2440,16 +2478,24 @@ async def get_latest_script_version(platform: str = "macos"):
|
||||
@router.get("/{agent_name}/upgrade-status")
|
||||
async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = Header(None)):
|
||||
"""Get agent upgrade status - used by agents to check if they should upgrade"""
|
||||
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). Checked before any
|
||||
# DB work; deployed agents always send X-API-Key. Raised before the try so the
|
||||
# 401 is not swallowed; validate_agent_api_key(None) returns None without a DB hit.
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key for agent '{agent_name}' upgrade status")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
try:
|
||||
conn = await get_database_connection()
|
||||
|
||||
|
||||
# Check if agent has upgrade pending (include platform and pool for validation)
|
||||
agent = await conn.fetchrow("""
|
||||
SELECT status, version as current_version, platform, pool_id
|
||||
FROM agents
|
||||
FROM agents
|
||||
WHERE name = $1
|
||||
""", agent_name)
|
||||
|
||||
|
||||
if not agent:
|
||||
await close_database_connection(conn)
|
||||
return {
|
||||
@@ -2457,26 +2503,15 @@ async def get_agent_upgrade_status(agent_name: str, x_api_key: Optional[str] = H
|
||||
"target_version": "",
|
||||
"message": "Agent not found"
|
||||
}
|
||||
|
||||
# Validate agent API key
|
||||
# API key is global - can be used for multiple agents
|
||||
if x_api_key:
|
||||
from auth_middleware import validate_agent_api_key
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if not agent_auth:
|
||||
await close_database_connection(conn)
|
||||
logger.warning(f"Invalid API key provided for agent '{agent_name}' upgrade status")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
# Log which agent's API key was used (for audit trail)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.debug(f"Agent '{agent_name}' checking upgrade status using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' checking upgrade status using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
logger.debug(f"Upgrade status check authorized for agent '{agent_name}'")
|
||||
|
||||
|
||||
# Log which agent's API key was used (for audit trail)
|
||||
if agent_auth['name'] == agent_name:
|
||||
logger.debug(f"Agent '{agent_name}' checking upgrade status using its own API key")
|
||||
else:
|
||||
logger.info(f"Agent '{agent_name}' checking upgrade status using API key from agent '{agent_auth['name']}'")
|
||||
|
||||
logger.debug(f"Upgrade status check authorized for agent '{agent_name}'")
|
||||
|
||||
await close_database_connection(conn)
|
||||
|
||||
# Agent should upgrade if status is 'upgrading'
|
||||
@@ -2910,7 +2945,17 @@ async def get_agent_script_template(platform: str, authorization: str = Header(N
|
||||
"""Get the latest script template for specified platform from database"""
|
||||
try:
|
||||
current_user = await get_current_user_from_token(authorization)
|
||||
|
||||
|
||||
# SECURITY (GHSA-7rhv-c5pc-69r8): the raw install/upgrade script is a
|
||||
# version-management surface. Gate reads with agents.version too, matching
|
||||
# the write path above (operator/security_admin/super_admin retain access).
|
||||
has_permission = await check_user_permission(current_user["id"], "agents", "version")
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Insufficient permissions: agents.version required"
|
||||
)
|
||||
|
||||
conn = await get_database_connection()
|
||||
|
||||
# Get latest script template for platform
|
||||
@@ -2961,7 +3006,19 @@ async def save_agent_script_template(platform: str, template_data: dict, authori
|
||||
"""Save updated script template to database using shared helper function"""
|
||||
try:
|
||||
current_user = await get_current_user_from_token(authorization)
|
||||
|
||||
|
||||
# SECURITY (GHSA-7rhv-c5pc-69r8): agent script templates become the
|
||||
# install/self-upgrade script executed as root on HAProxy nodes. A poisoned
|
||||
# template is RCE. Authentication alone is NOT enough — require the same
|
||||
# agents.version permission as POST /versions; otherwise any JWT holder
|
||||
# (including viewer) could overwrite the active script.
|
||||
has_permission = await check_user_permission(current_user["id"], "agents", "version")
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Insufficient permissions: agents.version required"
|
||||
)
|
||||
|
||||
script_content = template_data.get('script_content', '')
|
||||
version = template_data.get('version', '')
|
||||
|
||||
|
||||
@@ -3696,17 +3696,19 @@ async def confirm_restore_config_version(
|
||||
# UPDATE existing frontend (ALL 8 parsed fields)
|
||||
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
|
||||
await conn.execute("""
|
||||
UPDATE frontends
|
||||
SET bind_address = $1, bind_port = $2, default_backend = $3,
|
||||
UPDATE frontends
|
||||
SET bind_address = $1, bind_port = $2, default_backend = $3,
|
||||
mode = $4, ssl_enabled = $5, ssl_port = $6,
|
||||
maxconn = $7, timeout_client = $8,
|
||||
log_format = $11, filters = $12,
|
||||
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
|
||||
WHERE id = $9 AND cluster_id = $10
|
||||
""",
|
||||
""",
|
||||
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
|
||||
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
||||
parsed_fe.maxconn, parsed_fe.timeout_client,
|
||||
fe_id, cluster_id
|
||||
fe_id, cluster_id,
|
||||
parsed_fe.log_format, parsed_fe.filters # Issue #38
|
||||
)
|
||||
changes_summary["frontends_updated"] += 1
|
||||
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
||||
@@ -3714,16 +3716,17 @@ async def confirm_restore_config_version(
|
||||
# CREATE new frontend (ALL 8 parsed fields)
|
||||
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
|
||||
await conn.execute("""
|
||||
INSERT INTO frontends
|
||||
INSERT INTO frontends
|
||||
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
|
||||
maxconn, timeout_client,
|
||||
cluster_id, is_active, last_config_status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
cluster_id, log_format, filters, is_active, last_config_status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""",
|
||||
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
|
||||
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
|
||||
parsed_fe.maxconn, parsed_fe.timeout_client,
|
||||
cluster_id
|
||||
cluster_id,
|
||||
parsed_fe.log_format, parsed_fe.filters # Issue #38
|
||||
)
|
||||
changes_summary["frontends_created"] += 1
|
||||
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
|
||||
|
||||
+101
-12
@@ -3,7 +3,7 @@ Configuration Management and Validation API
|
||||
Provides endpoints for HAProxy configuration validation, templates, and optimization
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Header, Request
|
||||
from fastapi import APIRouter, HTTPException, Header, Request, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, List, Any, Optional
|
||||
import logging
|
||||
@@ -18,7 +18,7 @@ from utils.config_templates import (
|
||||
)
|
||||
from utils.haproxy_config_parser import parse_haproxy_config
|
||||
from utils.logging_config import log_with_correlation, PerformanceLogger
|
||||
from auth_middleware import get_current_user_from_token
|
||||
from auth_middleware import get_current_user_from_token, require_authenticated_user
|
||||
from database.connection import get_database_connection, close_database_connection
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["Configuration Management"])
|
||||
@@ -62,7 +62,7 @@ class ConfigOptimizationRequest(BaseModel):
|
||||
optimization_level: str = "balanced" # conservative, balanced, aggressive
|
||||
target_environment: str = "production" # development, staging, production
|
||||
|
||||
@router.post("/validate")
|
||||
@router.post("/validate", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was optional-auth; runs HAProxy validator on caller input
|
||||
async def validate_configuration(
|
||||
request: ConfigValidationRequest,
|
||||
current_user: dict = None,
|
||||
@@ -203,7 +203,7 @@ async def get_template_details(template_id: str):
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get template: {str(e)}")
|
||||
|
||||
@router.post("/templates/{template_id}/generate")
|
||||
@router.post("/templates/{template_id}/generate", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was optional-auth
|
||||
async def generate_configuration(
|
||||
template_id: str,
|
||||
request: TemplateGenerationRequest,
|
||||
@@ -271,7 +271,7 @@ async def generate_configuration(
|
||||
detail=f"Configuration generation failed: {str(e)}"
|
||||
)
|
||||
|
||||
@router.post("/optimize")
|
||||
@router.post("/optimize", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was optional-auth
|
||||
async def optimize_configuration(
|
||||
request: ConfigOptimizationRequest,
|
||||
current_user: dict = None,
|
||||
@@ -855,6 +855,9 @@ async def parse_bulk_config(
|
||||
"response_headers": frontend.response_headers,
|
||||
"options": frontend.options,
|
||||
"tcp_request_rules": frontend.tcp_request_rules,
|
||||
# Issue #38: SPOE filters + frontend log-format
|
||||
"log_format": frontend.log_format,
|
||||
"filters": frontend.filters,
|
||||
# CRITICAL: SSL Advanced Options (parsed from bind directive)
|
||||
"ssl_alpn": frontend.ssl_alpn,
|
||||
"ssl_npn": frontend.ssl_npn,
|
||||
@@ -1089,7 +1092,69 @@ async def parse_bulk_config(
|
||||
|
||||
# Add auto-assignment info at the beginning
|
||||
enhanced_warnings = ssl_auto_assign_info + enhanced_warnings
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Issue #38: SPOE pre-flight advisories. Surface, at preview time, the
|
||||
# SPOE configurations that would FAIL HAProxy's `haproxy -c` at apply so
|
||||
# the operator sees them BEFORE importing. Cluster-aware: the referenced
|
||||
# SPOE engine config (e.g. coraza.cfg) is a sibling of the cluster's
|
||||
# haproxy_config_path, which HAProxy OpenManager does not provision.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
try:
|
||||
_cfg_path = await conn.fetchval(
|
||||
"SELECT haproxy_config_path FROM haproxy_clusters WHERE id = $1",
|
||||
request.cluster_id,
|
||||
) or "/etc/haproxy/haproxy.cfg"
|
||||
_cfg_dir = _cfg_path.rsplit("/", 1)[0] or "/etc/haproxy"
|
||||
for _fe in frontends_data:
|
||||
_rh = _fe.get("request_headers") or ""
|
||||
_filters = _fe.get("filters") or ""
|
||||
# engines declared by `filter spoe engine <name> config <path>`
|
||||
_declared_engines = set(re.findall(
|
||||
r"filter\s+spoe\s+engine\s+(\S+)", _filters, re.IGNORECASE))
|
||||
# engines referenced by `... send-spoe-group <name> <group>`
|
||||
_used_engines = set(re.findall(
|
||||
r"send-spoe-group\s+(\S+)", _rh, re.IGNORECASE))
|
||||
_missing = _used_engines - _declared_engines
|
||||
if _missing:
|
||||
enhanced_warnings.append(
|
||||
f"⚠️ Frontend '{_fe['name']}': 'send-spoe-group' references SPOE "
|
||||
f"engine(s) {', '.join(sorted(_missing))} but no matching "
|
||||
f"'filter spoe engine <name> ...' line was found. HAProxy will "
|
||||
f"reject this at apply with \"unable to find SPOE engine\". Add the "
|
||||
f"filter line to this frontend."
|
||||
)
|
||||
for _path in re.findall(
|
||||
r"filter\s+spoe\s+engine\s+\S+\s+config\s+(\S+)",
|
||||
_filters, re.IGNORECASE):
|
||||
enhanced_warnings.append(
|
||||
f"ℹ️ Frontend '{_fe['name']}': SPOE engine config '{_path}' and its "
|
||||
f"SPOA backend must exist on the HAProxy host (cluster config dir: "
|
||||
f"{_cfg_dir}). HAProxy OpenManager preserves the filter directive but "
|
||||
f"does not provision these files; otherwise 'haproxy -c' fails at apply."
|
||||
)
|
||||
# Issue #38 follow-up: ACL `-f <file>` pattern-file advisory.
|
||||
# Scan only the structured rule fields (acl/use_backend) —
|
||||
# request_headers/tcp_request_rules were always free-form and
|
||||
# warning on them now would add new noise for existing users.
|
||||
_pattern_paths = []
|
||||
for _rule in (_fe.get("acl_rules") or []) + (_fe.get("use_backend_rules") or []):
|
||||
if isinstance(_rule, str):
|
||||
_pattern_paths.extend(
|
||||
re.findall(r"(?:^|\s)-f\s+(\S+)", _rule))
|
||||
if _pattern_paths:
|
||||
_uniq = sorted(set(_pattern_paths))
|
||||
enhanced_warnings.append(
|
||||
f"ℹ️ Frontend '{_fe['name']}': ACL/routing rules reference pattern "
|
||||
f"file(s) {', '.join(_uniq)}. Each file must exist at that exact path "
|
||||
f"on every HAProxy host in the cluster (cluster config dir: {_cfg_dir}) "
|
||||
f"— HAProxy OpenManager does not create or distribute pattern files. "
|
||||
f"A missing file fails safely at 'haproxy -c' (previous config keeps "
|
||||
f"running)."
|
||||
)
|
||||
except Exception as _spoe_adv_err:
|
||||
logger.warning(f"SPOE advisory generation skipped: {_spoe_adv_err}")
|
||||
|
||||
# BULK IMPORT MVP: Check existing entities for UPSERT detection
|
||||
# Mark each entity as new or update for UI display
|
||||
# CRITICAL: Only mark as UPDATE if there are actual field changes
|
||||
@@ -1150,7 +1215,17 @@ async def parse_bulk_config(
|
||||
if frontend.get("tcp_request_rules") and frontend["tcp_request_rules"] != existing["tcp_request_rules"]:
|
||||
has_changes = True
|
||||
changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]}
|
||||
|
||||
# Issue #38: SPOE filters + log-format change detection. REQUIRED for
|
||||
# persistence (not just display): without it, an import that only adds
|
||||
# a `filter`/`log-format` to an existing frontend would be flagged
|
||||
# "no change" and the directive would never be written to the DB.
|
||||
if frontend.get("log_format") and frontend["log_format"] != existing.get("log_format"):
|
||||
has_changes = True
|
||||
changes["log_format"] = {"old": existing.get("log_format"), "new": frontend["log_format"]}
|
||||
if frontend.get("filters") and frontend["filters"] != existing.get("filters"):
|
||||
has_changes = True
|
||||
changes["filters"] = {"old": existing.get("filters"), "new": frontend["filters"]}
|
||||
|
||||
# CRITICAL: SSL Advanced Options change detection
|
||||
if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"):
|
||||
has_changes = True
|
||||
@@ -2094,7 +2169,18 @@ async def bulk_create_entities(
|
||||
update_fields.append(f"options = ${param_index}")
|
||||
update_values.append(frontend_data["options"])
|
||||
param_index += 1
|
||||
|
||||
|
||||
# Issue #38: SPOE filters + frontend log-format (merge strategy)
|
||||
if frontend_data.get("log_format") and frontend_data["log_format"] != existing_full.get("log_format"):
|
||||
update_fields.append(f"log_format = ${param_index}")
|
||||
update_values.append(frontend_data["log_format"])
|
||||
param_index += 1
|
||||
|
||||
if frontend_data.get("filters") and frontend_data["filters"] != existing_full.get("filters"):
|
||||
update_fields.append(f"filters = ${param_index}")
|
||||
update_values.append(frontend_data["filters"])
|
||||
param_index += 1
|
||||
|
||||
# CRITICAL FIX: Update SSL advanced options (alpn, npn, ciphers, etc.)
|
||||
# These are parsed from bind directive and should be preserved in database
|
||||
if "ssl_alpn" in frontend_data and frontend_data.get("ssl_alpn") != existing_full.get("ssl_alpn"):
|
||||
@@ -2214,9 +2300,10 @@ async def bulk_create_entities(
|
||||
timeout_client, timeout_http_request, maxconn,
|
||||
request_headers, response_headers, tcp_request_rules, options,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
cluster_id, acl_rules, use_backend_rules, redirect_rules, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
|
||||
cluster_id, acl_rules, use_backend_rules, redirect_rules,
|
||||
log_format, filters, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""",
|
||||
frontend_data["name"],
|
||||
@@ -2257,7 +2344,9 @@ async def bulk_create_entities(
|
||||
request.cluster_id,
|
||||
json.dumps(frontend_data.get("acl_rules", [])), # acl_rules
|
||||
json.dumps(frontend_data.get("use_backend_rules", [])), # use_backend_rules
|
||||
json.dumps([]) # redirect_rules
|
||||
json.dumps([]), # redirect_rules
|
||||
frontend_data.get("log_format"), # Issue #38
|
||||
frontend_data.get("filters") # Issue #38
|
||||
)
|
||||
|
||||
created_entities["frontends"].append({
|
||||
|
||||
@@ -201,13 +201,15 @@ async def get_pending_config_requests(agent_name: str, x_api_key: Optional[str]
|
||||
Called during heartbeat.
|
||||
"""
|
||||
try:
|
||||
# Validate agent API key
|
||||
# Validate agent API key — MANDATORY (GHSA-3p5c-m5m4-mjpx). Deployed agents
|
||||
# always send X-API-Key; an absent/invalid key is unauthenticated. This
|
||||
# endpoint also mutates state (marks requests 'processing'), so a keyless
|
||||
# caller could otherwise starve the real agent.
|
||||
agent_auth = await validate_agent_api_key(x_api_key)
|
||||
|
||||
if x_api_key and not agent_auth:
|
||||
logger.warning(f"Invalid API key provided by agent '{agent_name}' for pending requests")
|
||||
raise HTTPException(status_code=401, detail="Invalid API key")
|
||||
|
||||
if not agent_auth:
|
||||
logger.warning(f"Missing/invalid API key from '{agent_name}' for pending requests")
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
conn = await get_database_connection()
|
||||
|
||||
# Get pending requests
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException, Header
|
||||
from fastapi import APIRouter, HTTPException, Header, Depends
|
||||
from auth_middleware import require_authenticated_user
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
@@ -11,7 +12,7 @@ from agent_notifications import get_cluster_agents_status
|
||||
router = APIRouter(prefix="/api", tags=["dashboard", "pools"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.get("/dashboard/overview")
|
||||
@router.get("/dashboard/overview", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaked cluster/pool/agent stats, names, health & alerts anonymously (auth was optional)
|
||||
async def get_dashboard_overview(cluster_id: Optional[int] = None, authorization: str = Header(None)):
|
||||
"""Get dashboard overview with comprehensive statistics, optionally filtered by cluster"""
|
||||
try:
|
||||
@@ -238,7 +239,7 @@ async def get_dashboard_overview(cluster_id: Optional[int] = None, authorization
|
||||
logger.error(f"Error fetching dashboard overview: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/dashboard/stats")
|
||||
@router.get("/dashboard/stats", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): aggregate cluster/agent counts
|
||||
async def get_dashboard_stats():
|
||||
"""Get dashboard statistics"""
|
||||
try:
|
||||
@@ -279,7 +280,7 @@ async def get_dashboard_stats():
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/pools")
|
||||
@router.get("/pools", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): pool names/env/counts
|
||||
async def get_pools():
|
||||
"""Get all HAProxy cluster pools"""
|
||||
try:
|
||||
@@ -350,7 +351,7 @@ async def get_pools():
|
||||
logger.error(f"Error fetching pools: {e}")
|
||||
return {"pools": []}
|
||||
|
||||
@router.get("/haproxy-cluster-pools")
|
||||
@router.get("/haproxy-cluster-pools", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c)
|
||||
async def get_haproxy_cluster_pools():
|
||||
"""Get all HAProxy cluster pools (legacy endpoint)"""
|
||||
# Just call the main pools endpoint
|
||||
@@ -474,7 +475,7 @@ async def update_pool(pool_id: int, pool: PoolUpdate, authorization: str = Heade
|
||||
logger.error(f"Failed to update pool: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update pool: {str(e)}")
|
||||
|
||||
@router.get("/haproxy-cluster-pools/{pool_id}/agents")
|
||||
@router.get("/haproxy-cluster-pools/{pool_id}/agents", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): full agent inventory — same class as GET /api/agents
|
||||
async def get_pool_agents(pool_id: int):
|
||||
"""Get all agents for a specific pool"""
|
||||
try:
|
||||
@@ -561,7 +562,7 @@ async def get_pool_agents(pool_id: int):
|
||||
logger.error(f"Error fetching pool agents: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch pool agents: {str(e)}")
|
||||
|
||||
@router.get("/haproxy/stats")
|
||||
@router.get("/haproxy/stats", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c)
|
||||
async def get_haproxy_stats(cluster_id: Optional[int] = None):
|
||||
"""Get HAProxy statistics"""
|
||||
try:
|
||||
|
||||
@@ -3,13 +3,22 @@ Dashboard Stats Router
|
||||
API endpoints for HAProxy statistics dashboard
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
|
||||
from services.dashboard_stats_service import dashboard_stats_service
|
||||
from auth_middleware import require_authenticated_user
|
||||
|
||||
router = APIRouter(prefix="/api/dashboard-stats", tags=["dashboard-stats"])
|
||||
# SECURITY (GHSA-3p5c-m5m4-mjpx): this entire router (traffic metrics, backend
|
||||
# health, cluster topology, agent status) was mounted without authentication.
|
||||
# Require a valid JWT on every route. The frontend Dashboard already sends the
|
||||
# operator JWT on these calls, so this is transparent to the UI.
|
||||
router = APIRouter(
|
||||
prefix="/api/dashboard-stats",
|
||||
tags=["dashboard-stats"],
|
||||
dependencies=[Depends(require_authenticated_user)],
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
+69
-12
@@ -117,6 +117,41 @@ def _rule_contradiction_text(rule: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _pattern_file_warnings(
|
||||
acl_rules: Optional[List[Any]] = None,
|
||||
use_backend_rules: Optional[List[Any]] = None,
|
||||
redirect_rules: Optional[List[Any]] = None,
|
||||
) -> List[str]:
|
||||
"""Issue #38 follow-up — non-blocking `-f <file>` pattern-file
|
||||
advisory for the manual frontend API.
|
||||
|
||||
The Bulgu #12 hard reject was removed from the Pydantic models:
|
||||
pattern files are operator-managed host files (same policy as the
|
||||
SPOE `filter ... config <path>` reference preserved since v1.8.8)
|
||||
and the agent's pre-reload `haproxy -c` makes a missing file fail
|
||||
safely. This helper returns one warning listing the unique file
|
||||
paths referenced across the rule fields, or [] when no rule uses
|
||||
`-f` — operators who don't use pattern files see no change.
|
||||
"""
|
||||
paths: List[str] = []
|
||||
for rules in (acl_rules, use_backend_rules, redirect_rules):
|
||||
for rule in rules or []:
|
||||
text = rule if isinstance(rule, str) else (
|
||||
rule.get("condition") if isinstance(rule, dict) else None)
|
||||
if isinstance(text, str):
|
||||
paths.extend(re.findall(r"(?:^|\s)-f\s+(\S+)", text))
|
||||
if not paths:
|
||||
return []
|
||||
uniq = sorted(set(paths))
|
||||
return [
|
||||
f"ACL/routing rules reference pattern file(s) {', '.join(uniq)}. "
|
||||
f"Each file must exist at that exact path on every HAProxy host "
|
||||
f"in the cluster — HAProxy OpenManager does not create or "
|
||||
f"distribute pattern files. A missing file fails safely at "
|
||||
f"'haproxy -c' (the previous config keeps running)."
|
||||
]
|
||||
|
||||
|
||||
def _collect_routing_rule_contradictions(
|
||||
rules: List[Any], origin_label: str,
|
||||
) -> List[Tuple[str, Any]]:
|
||||
@@ -408,6 +443,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -424,6 +460,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -453,6 +490,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -465,6 +503,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -480,6 +519,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -492,6 +532,7 @@ async def get_frontends(
|
||||
ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites, ssl_min_ver, ssl_max_ver, ssl_strict_sni,
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules,
|
||||
log_format, filters,
|
||||
timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
maxconn, is_active, created_at, updated_at, cluster_id, last_config_status
|
||||
@@ -601,6 +642,8 @@ async def get_frontends(
|
||||
"response_headers": f.get("response_headers"),
|
||||
"options": f.get("options"),
|
||||
"tcp_request_rules": f.get("tcp_request_rules"),
|
||||
"log_format": f.get("log_format"), # Issue #38
|
||||
"filters": f.get("filters"), # Issue #38
|
||||
"timeout_client": f.get("timeout_client"),
|
||||
"timeout_http_request": f.get("timeout_http_request"),
|
||||
"rate_limit": f.get("rate_limit"),
|
||||
@@ -735,18 +778,18 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
cluster_id, maxconn, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, CURRENT_TIMESTAMP)
|
||||
cluster_id, maxconn, log_format, filters, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
frontend.default_backend, frontend.mode, frontend.ssl_enabled,
|
||||
frontend.ssl_certificate_id, ssl_cert_ids_json, frontend.ssl_port, frontend.ssl_cert_path, frontend.ssl_cert, frontend.ssl_verify,
|
||||
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
|
||||
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
|
||||
frontend.ssl_min_ver, frontend.ssl_max_ver, frontend.ssl_strict_sni,
|
||||
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
||||
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
||||
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
||||
frontend.cluster_id, frontend.maxconn)
|
||||
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters)
|
||||
|
||||
# If cluster_id provided, create new config version for agents
|
||||
sync_results = []
|
||||
@@ -825,12 +868,19 @@ async def create_frontend(frontend: FrontendConfig, request: Request, authorizat
|
||||
user_agent=request.headers.get('user-agent')
|
||||
)
|
||||
|
||||
return {
|
||||
response: dict = {
|
||||
"message": f"Frontend '{frontend.name}' created successfully",
|
||||
"id": frontend_id,
|
||||
"frontend": frontend.dict(),
|
||||
"sync_results": sync_results
|
||||
}
|
||||
# Issue #38 follow-up — non-blocking pattern-file advisory
|
||||
# (additive field; absent when no rule references `-f`).
|
||||
pattern_warnings = _pattern_file_warnings(
|
||||
frontend.acl_rules, frontend.use_backend_rules, frontend.redirect_rules)
|
||||
if pattern_warnings:
|
||||
response["warnings"] = pattern_warnings
|
||||
return response
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -1060,9 +1110,10 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
acl_rules = $20, redirect_rules = $21, use_backend_rules = $22,
|
||||
request_headers = $23, response_headers = $24, options = $25, tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
|
||||
rate_limit = $29, compression = $30, log_separate = $31, monitor_uri = $32,
|
||||
cluster_id = $33, maxconn = $34, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $35
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
cluster_id = $33, maxconn = $34, log_format = $35, filters = $36,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $37
|
||||
""", frontend.name, frontend.bind_address, frontend.bind_port,
|
||||
frontend.default_backend, frontend.mode, ssl_enabled,
|
||||
ssl_certificate_id, ssl_cert_ids_json, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
|
||||
frontend.ssl_alpn, frontend.ssl_npn, frontend.ssl_ciphers, frontend.ssl_ciphersuites,
|
||||
@@ -1070,7 +1121,7 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
json.dumps(frontend.acl_rules or []), json.dumps(frontend.redirect_rules or []), json.dumps(frontend.use_backend_rules or []),
|
||||
frontend.request_headers, frontend.response_headers, filtered_options, frontend.tcp_request_rules, frontend.timeout_client, frontend.timeout_http_request,
|
||||
frontend.rate_limit, frontend.compression, frontend.log_separate, frontend.monitor_uri,
|
||||
frontend.cluster_id, frontend.maxconn, frontend_id)
|
||||
frontend.cluster_id, frontend.maxconn, frontend.log_format, frontend.filters, frontend_id)
|
||||
|
||||
# Debug: Check what was actually saved
|
||||
updated_frontend = await conn.fetchrow("""
|
||||
@@ -1124,6 +1175,8 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
"response_headers": frontend.response_headers,
|
||||
"options": filtered_options,
|
||||
"tcp_request_rules": frontend.tcp_request_rules,
|
||||
"log_format": frontend.log_format, # Issue #38
|
||||
"filters": frontend.filters, # Issue #38
|
||||
"timeout_client": frontend.timeout_client,
|
||||
"timeout_http_request": frontend.timeout_http_request,
|
||||
"rate_limit": frontend.rate_limit,
|
||||
@@ -1235,8 +1288,12 @@ async def update_frontend(frontend_id: int, frontend: FrontendConfig, request: R
|
||||
# yellow toast on the next refresh. The save SUCCEEDED; the
|
||||
# warnings only flag latent legacy data the operator may
|
||||
# want to clean up at their convenience.
|
||||
if contradiction_warnings:
|
||||
response["warnings"] = contradiction_warnings
|
||||
# Issue #38 follow-up — append the pattern-file advisory to
|
||||
# the same list (additive; empty when no rule uses `-f`).
|
||||
all_warnings = list(contradiction_warnings or []) + _pattern_file_warnings(
|
||||
frontend.acl_rules, frontend.use_backend_rules, frontend.redirect_rules)
|
||||
if all_warnings:
|
||||
response["warnings"] = all_warnings
|
||||
return response
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
@@ -3,8 +3,9 @@ Production-Ready Health Check and Monitoring Endpoints
|
||||
Provides comprehensive system health monitoring for Kubernetes and production environments
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from auth_middleware import require_authenticated_user
|
||||
import logging
|
||||
import asyncio
|
||||
import time
|
||||
@@ -74,7 +75,7 @@ async def readiness_probe():
|
||||
logger.error(f"Readiness probe failed: {e}")
|
||||
raise HTTPException(status_code=503, detail=f"Not ready: {str(e)}")
|
||||
|
||||
@router.get("/deep")
|
||||
@router.get("/deep", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaks host CPU/mem/disk, DB/redis/python versions, PID
|
||||
async def deep_health_check():
|
||||
"""Comprehensive health check with detailed system information"""
|
||||
global _health_cache
|
||||
@@ -197,7 +198,7 @@ async def deep_health_check():
|
||||
|
||||
raise HTTPException(status_code=503, detail=error_response)
|
||||
|
||||
@router.get("/agents")
|
||||
@router.get("/agents", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaks agent names/hostnames
|
||||
async def agents_health():
|
||||
"""Monitor agent connectivity and health status"""
|
||||
try:
|
||||
@@ -261,7 +262,7 @@ async def agents_health():
|
||||
logger.error(f"Agent health check failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Agent health check failed: {str(e)}")
|
||||
|
||||
@router.get("/clusters")
|
||||
@router.get("/clusters", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): leaks cluster names, HAProxy versions, pending counts
|
||||
async def clusters_health():
|
||||
"""Monitor HAProxy cluster health and configuration status"""
|
||||
try:
|
||||
@@ -329,7 +330,7 @@ async def clusters_health():
|
||||
logger.error(f"Cluster health check failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Cluster health check failed: {str(e)}")
|
||||
|
||||
@router.get("/errors")
|
||||
@router.get("/errors", dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): app error metrics, sibling of /deep,/agents,/clusters
|
||||
async def error_statistics():
|
||||
"""Get application error statistics and metrics"""
|
||||
try:
|
||||
|
||||
@@ -87,6 +87,32 @@ class AccountCreate(BaseModel):
|
||||
raise ValueError("eab_hmac_key is not valid base64; copy it exactly from your CA account.")
|
||||
return v
|
||||
|
||||
@field_validator('directory_url')
|
||||
@classmethod
|
||||
def _validate_directory_url(cls, v):
|
||||
# SECURITY (GHSA-3vh4-gvxx-wm2p): reject non-https URLs and literal
|
||||
# non-public IP hosts at the API boundary. The full DNS-based SSRF check
|
||||
# runs at fetch time (acme_service.get_directory -> ssrf_guard).
|
||||
if not v:
|
||||
return v
|
||||
from urllib.parse import urlparse
|
||||
import ipaddress
|
||||
from utils.ssrf_guard import is_public_ip
|
||||
parsed = urlparse(v.strip())
|
||||
if parsed.scheme.lower() != 'https':
|
||||
raise ValueError("directory_url must be an https URL")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise ValueError("directory_url has no host")
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
is_ip_literal = True
|
||||
except ValueError:
|
||||
is_ip_literal = False
|
||||
if is_ip_literal and not is_public_ip(host):
|
||||
raise ValueError("directory_url must not point to a private/loopback IP address")
|
||||
return v
|
||||
|
||||
@model_validator(mode='after')
|
||||
def _require_provider_for_dns01(self):
|
||||
if self.challenge_type == 'dns-01' and not (self.dns_provider or '').strip():
|
||||
|
||||
@@ -104,16 +104,40 @@ async def test_acme_connection(authorization: str = Header(None), 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() as session:
|
||||
async with session.get(str(directory_url), timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
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()
|
||||
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": str(directory_url),
|
||||
"endpoints": list(data.keys()) if isinstance(data, dict) else []
|
||||
"directory": directory_url,
|
||||
"endpoints": present,
|
||||
}
|
||||
else:
|
||||
return {"success": False, "error": f"HTTP {resp.status} from directory URL"}
|
||||
|
||||
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
|
||||
|
||||
# Import database and models
|
||||
from database.connection import get_database_connection, close_database_connection
|
||||
from auth_middleware import get_current_user_from_token
|
||||
from auth_middleware import get_current_user_from_token, require_authenticated_user
|
||||
from models.ssl import SSLCertificate, SSLCertificateCreate, SSLCertificateUpdate, SSLCertificateResponse
|
||||
from utils.ssl_parser import parse_ssl_certificate, validate_private_key, validate_certificate_chain, format_certificate_info
|
||||
from utils.activity_log import log_user_activity
|
||||
@@ -825,7 +825,8 @@ async def get_ssl_certificate(cert_id: int, authorization: str = Header(None)):
|
||||
logger.error(f"Error getting SSL certificate: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/certificates/{cert_id}/config-versions")
|
||||
@router.get("/certificates/{cert_id}/config-versions",
|
||||
dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): was unauthenticated
|
||||
async def get_ssl_certificate_config_versions(cert_id: int):
|
||||
"""Get config version history for specific SSL certificate"""
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from fastapi import APIRouter, HTTPException, Request, Header
|
||||
from fastapi import APIRouter, HTTPException, Request, Header, Depends
|
||||
from auth_middleware import require_authenticated_user
|
||||
from typing import Optional
|
||||
import logging
|
||||
import time
|
||||
@@ -182,7 +183,8 @@ async def get_waf_stats(cluster_id: Optional[int] = None, authorization: str = H
|
||||
logger.error(f"Error fetching WAF stats: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/rules", summary="Get WAF Rules", response_description="List of WAF rules")
|
||||
@router.get("/rules", summary="Get WAF Rules", response_description="List of WAF rules",
|
||||
dependencies=[Depends(require_authenticated_user)]) # SECURITY (GHSA-3p5c): WAF rule definitions aid bypass crafting
|
||||
async def get_waf_rules(cluster_id: Optional[int] = None):
|
||||
"""
|
||||
# Get WAF Rules
|
||||
|
||||
@@ -69,8 +69,14 @@ class ACMEService:
|
||||
if cached.get('_fetched_at', 0) > time.time() - 3600:
|
||||
return cached
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(directory_url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
|
||||
# SECURITY (GHSA-3vh4-gvxx-wm2p): directory_url can come from a stored
|
||||
# account row; validate it (https + public IP, no redirects) before the
|
||||
# server-side fetch so it cannot be pointed at internal/metadata targets.
|
||||
from utils.ssrf_guard import assert_public_url, safe_connector
|
||||
await assert_public_url(directory_url)
|
||||
|
||||
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||
async with session.get(directory_url, timeout=aiohttp.ClientTimeout(total=15), allow_redirects=False) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to fetch ACME directory: HTTP {resp.status}")
|
||||
data = await resp.json()
|
||||
@@ -90,8 +96,16 @@ class ACMEService:
|
||||
cached = self._nonce_by_dir.pop(directory_url, None)
|
||||
if cached:
|
||||
return cached
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.head(directory['newNonce']) as resp:
|
||||
# SECURITY (GHSA-3vh4-gvxx-wm2p): newNonce is taken from the (attacker-
|
||||
# influenceable) directory JSON and is fetched here BEFORE the guarded
|
||||
# _signed_request POST, so it must be guarded too — otherwise a directory
|
||||
# that returns an internal newNonce (and omits Replay-Nonce) is a live SSRF.
|
||||
# https + public IP only, IPv4-pinned connector, no redirects, bounded timeout.
|
||||
from utils.ssrf_guard import assert_public_url, safe_connector
|
||||
nonce_url = directory['newNonce']
|
||||
await assert_public_url(nonce_url)
|
||||
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||
async with session.head(nonce_url, timeout=aiohttp.ClientTimeout(total=15), allow_redirects=False) as resp:
|
||||
return resp.headers['Replay-Nonce']
|
||||
|
||||
def _generate_account_key(self) -> Tuple[str, dict]:
|
||||
@@ -187,13 +201,21 @@ class ACMEService:
|
||||
|
||||
body = self._sign_jws(private_key, protected, payload)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# SECURITY (GHSA-3vh4-gvxx-wm2p): `url` is taken from the CA directory /
|
||||
# order responses. The directory is already fetched from a validated
|
||||
# public CA, but guard the follow-up POST target too (defence in depth)
|
||||
# so a tampered/malicious directory cannot steer the request internally.
|
||||
from utils.ssrf_guard import assert_public_url, safe_connector
|
||||
await assert_public_url(url)
|
||||
|
||||
async with aiohttp.ClientSession(connector=safe_connector()) as session:
|
||||
for attempt in range(3):
|
||||
async with session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers={"Content-Type": "application/jose+json"},
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
allow_redirects=False,
|
||||
) as resp:
|
||||
if 'Replay-Nonce' in resp.headers:
|
||||
self._nonce_by_dir[directory_url] = resp.headers['Replay-Nonce']
|
||||
|
||||
@@ -56,14 +56,14 @@ async def create_frontend_row(
|
||||
acl_rules, redirect_rules, use_backend_rules,
|
||||
request_headers, response_headers, options, tcp_request_rules, timeout_client, timeout_http_request,
|
||||
rate_limit, compression, log_separate, monitor_uri,
|
||||
cluster_id, maxconn, updated_at
|
||||
cluster_id, maxconn, log_format, filters, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16, $17, $18, $19,
|
||||
$20, $21, $22,
|
||||
$23, $24, $25, $26, $27, $28,
|
||||
$29, $30, $31, $32,
|
||||
$33, $34, CURRENT_TIMESTAMP
|
||||
$33, $34, $35, $36, CURRENT_TIMESTAMP
|
||||
)
|
||||
RETURNING id
|
||||
""",
|
||||
@@ -101,6 +101,8 @@ async def create_frontend_row(
|
||||
getattr(payload, "monitor_uri", None),
|
||||
cluster_id,
|
||||
getattr(payload, "maxconn", None),
|
||||
getattr(payload, "log_format", None), # Issue #38
|
||||
getattr(payload, "filters", None), # Issue #38
|
||||
)
|
||||
|
||||
if mark_pending:
|
||||
|
||||
@@ -393,6 +393,12 @@ def _categorize_haproxy_directive(line: str) -> str:
|
||||
return "prelude"
|
||||
if s.startswith("acl "):
|
||||
return "acl"
|
||||
# Issue #38: SPOE (and other) `filter` directives must be declared BEFORE
|
||||
# the `http-request send-spoe-group` rules that use them, otherwise HAProxy
|
||||
# fails with "unable to find SPOE engine". Own bucket, flushed right after
|
||||
# `prelude` and before tcp_req/acl/http_req (see flush order below).
|
||||
if s.startswith("filter "):
|
||||
return "filter"
|
||||
if s.startswith("stick-table") or s.startswith("stick "):
|
||||
return "stick"
|
||||
if s.startswith("tcp-request"):
|
||||
@@ -414,6 +420,7 @@ def _categorize_haproxy_directive(line: str) -> str:
|
||||
or s.startswith("compression ")
|
||||
or s.startswith("monitor-uri")
|
||||
or s.startswith("log ")
|
||||
or s.startswith("log-format") # Issue #38: log-format / log-format-sd
|
||||
or s.startswith("description ")
|
||||
or s.startswith("disabled")
|
||||
or s.startswith("enabled")
|
||||
@@ -903,7 +910,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
# "stick-table already declared").
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
_fe_buckets: Dict[str, List[str]] = {
|
||||
"prelude": [], "stick": [], "tcp_req": [],
|
||||
"prelude": [], "filter": [], "stick": [], "tcp_req": [],
|
||||
"acl": [], "http_req": [], "http_resp": [],
|
||||
"redirect": [], "use_be": [], "default_be": [],
|
||||
}
|
||||
@@ -996,6 +1003,17 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
||||
_emit_fe(f" {line_stripped}")
|
||||
|
||||
# Issue #38: emit frontend log-format and SPOE (etc.) filter directives.
|
||||
# `log_format` routes to the `prelude` bucket, `filters` to the `filter`
|
||||
# bucket (both via _emit_fe → _categorize_haproxy_directive), guaranteeing
|
||||
# `filter ...` is rendered before the `http-request send-spoe-group` rules.
|
||||
for _fld in ('log_format', 'filters'):
|
||||
if frontend.get(_fld):
|
||||
for line in frontend[_fld].split('\n'):
|
||||
line_stripped = line.strip()
|
||||
if line_stripped and line_stripped not in ('[]', '{}', 'null', 'None'):
|
||||
_emit_fe(f" {line_stripped}")
|
||||
|
||||
# CRITICAL: Validate frontend-backend mode compatibility
|
||||
if frontend.get('default_backend'):
|
||||
default_backend_name = frontend['default_backend'].strip() if frontend['default_backend'] else ''
|
||||
@@ -1223,6 +1241,7 @@ async def generate_haproxy_config_for_cluster(cluster_id: int, conn: Optional[An
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
for _bucket_key in (
|
||||
"prelude",
|
||||
"filter",
|
||||
"stick",
|
||||
"tcp_req",
|
||||
"acl",
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Issue #38 follow-up — ACL `-f <file>` pattern-file support (v1.8.9).
|
||||
|
||||
The Bulgu #12 hard rejects were removed: pattern files are
|
||||
operator-managed host files (same policy as the SPOE
|
||||
`filter ... config <path>` reference preserved since v1.8.8), bulk
|
||||
import always accepted `-f`, and the agent runs `haproxy -c` before
|
||||
every reload so a missing file fails safely. These tests pin:
|
||||
|
||||
1. ACCEPT — the manual FrontendConfig model and the wizard models
|
||||
accept `-f` in every rule field (string + dict shapes).
|
||||
2. GUARDS KEPT — `$(`/backtick shell-substitution rejects and the
|
||||
`X !X` contradiction machinery are unchanged.
|
||||
3. WARNINGS — `_pattern_file_warnings` emits exactly one advisory
|
||||
listing the referenced files, and NOTHING for `-f`-free rules
|
||||
(zero-noise: existing users see no new output).
|
||||
4. ADVISORY — the bulk-import preview advisory block scans
|
||||
acl/use_backend rules (and only those fields).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from models.frontend import FrontendConfig # noqa: E402
|
||||
from routers.frontend import _pattern_file_warnings # noqa: E402
|
||||
|
||||
|
||||
ACL_F = "blacklisted src -f /etc/haproxy/blacklist.lst"
|
||||
UB_F = "be-secure if { src -f /etc/haproxy/allowlist.lst }"
|
||||
REDIR_F = "location /blocked if { src -f /etc/haproxy/blacklist.lst }"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 1. ACCEPT — manual FrontendConfig model
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_frontend_config_accepts_acl_file_flag():
|
||||
fe = FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[ACL_F])
|
||||
assert fe.acl_rules == [ACL_F]
|
||||
|
||||
|
||||
def test_frontend_config_accepts_use_backend_file_flag():
|
||||
fe = FrontendConfig(
|
||||
name="fe1", bind_port=80, mode="http", use_backend_rules=[UB_F])
|
||||
assert fe.use_backend_rules == [UB_F]
|
||||
|
||||
|
||||
def test_frontend_config_accepts_redirect_string_file_flag():
|
||||
fe = FrontendConfig(
|
||||
name="fe1", bind_port=80, mode="http", redirect_rules=[REDIR_F])
|
||||
assert fe.redirect_rules == [REDIR_F]
|
||||
|
||||
|
||||
def test_frontend_config_accepts_redirect_dict_file_flag():
|
||||
rule = {"type": "scheme", "scheme": "https",
|
||||
"condition": "if { src -f /etc/haproxy/blacklist.lst }"}
|
||||
fe = FrontendConfig(
|
||||
name="fe1", bind_port=80, mode="http", redirect_rules=[rule])
|
||||
assert fe.redirect_rules == [rule]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 2. GUARDS KEPT — dangerous-content rejects unchanged
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_rule", [
|
||||
"acl1 path $(rm -rf /)",
|
||||
"acl1 path `id`",
|
||||
])
|
||||
def test_acl_shell_substitution_still_rejected(bad_rule):
|
||||
from pydantic import ValidationError
|
||||
with pytest.raises(ValidationError):
|
||||
FrontendConfig(name="fe1", bind_port=80, mode="http", acl_rules=[bad_rule])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_rule", [
|
||||
"be1 if $(whoami)",
|
||||
"be1 if `id`",
|
||||
])
|
||||
def test_use_backend_shell_substitution_still_rejected(bad_rule):
|
||||
from pydantic import ValidationError
|
||||
with pytest.raises(ValidationError):
|
||||
FrontendConfig(
|
||||
name="fe1", bind_port=80, mode="http", use_backend_rules=[bad_rule])
|
||||
|
||||
|
||||
def test_contradiction_detection_still_works_on_file_flag_rules():
|
||||
"""Interaction guard: a `-f` rule with an `X !X` contradiction is
|
||||
still caught by the handler-level contradiction machinery — the
|
||||
`-f` relaxation must not weaken that gate."""
|
||||
from models.frontend import _frontend_has_acl_contradiction
|
||||
assert _frontend_has_acl_contradiction(
|
||||
"be1 if blacklisted !blacklisted") is True
|
||||
# And a normal -f rule is NOT a contradiction.
|
||||
assert _frontend_has_acl_contradiction(UB_F) is False
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 3. WARNINGS — _pattern_file_warnings (zero-noise contract)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_pattern_file_warnings_lists_unique_paths():
|
||||
warnings = _pattern_file_warnings(
|
||||
acl_rules=[ACL_F, "other src -f /etc/haproxy/blacklist.lst"],
|
||||
use_backend_rules=[UB_F],
|
||||
redirect_rules=[{"condition": "if { src -f /etc/haproxy/geo.lst }"}],
|
||||
)
|
||||
assert len(warnings) == 1
|
||||
w = warnings[0]
|
||||
assert "/etc/haproxy/blacklist.lst" in w
|
||||
assert "/etc/haproxy/allowlist.lst" in w
|
||||
assert "/etc/haproxy/geo.lst" in w
|
||||
# Duplicate path listed once.
|
||||
assert w.count("/etc/haproxy/blacklist.lst") == 1
|
||||
# Non-blocking framing: mentions fail-safe haproxy -c.
|
||||
assert "haproxy -c" in w
|
||||
|
||||
|
||||
def test_pattern_file_warnings_empty_without_file_flag():
|
||||
"""Zero-noise: operators who don't use `-f` must see NO warning."""
|
||||
assert _pattern_file_warnings(
|
||||
acl_rules=["is_api path_beg /api", "is_admin src 10.0.0.0/24"],
|
||||
use_backend_rules=["be-api if is_api"],
|
||||
redirect_rules=[{"type": "scheme", "scheme": "https",
|
||||
"condition": "if !{ ssl_fc }"}],
|
||||
) == []
|
||||
assert _pattern_file_warnings() == []
|
||||
|
||||
|
||||
def test_pattern_file_warnings_ignores_dash_f_substrings():
|
||||
"""`-file`/`-foo` substrings must not trigger the advisory."""
|
||||
assert _pattern_file_warnings(
|
||||
acl_rules=["is_self path_beg /self-config-file",
|
||||
"is_foo path_beg /foo -m beg"],
|
||||
) == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 4. Wizard models accept `-f` (string + dict) — parity
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wizard_models_accept_file_flag():
|
||||
from models.site_wizard import FrontendStep
|
||||
|
||||
fe = FrontendStep(
|
||||
name="fe1", mode="http", bind_address="*", bind_port=80,
|
||||
acl_rules=[ACL_F],
|
||||
use_backend_rules=["be-x if blacklisted"],
|
||||
redirect_rules=[{"type": "scheme", "target": "https",
|
||||
"condition": "if { src -f /etc/haproxy/x.lst }"}],
|
||||
)
|
||||
assert fe.acl_rules == [ACL_F]
|
||||
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/x.lst }"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# 5. Bulk-import preview advisory — source-level pin
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_bulk_advisory_scans_only_structured_rule_fields():
|
||||
"""The preview advisory scans acl_rules/use_backend_rules but NOT
|
||||
request_headers/tcp_request_rules (always-free-form fields —
|
||||
warning there would add new noise for existing users)."""
|
||||
src = Path(__file__).resolve().parents[1] / "routers" / "config.py"
|
||||
text = src.read_text()
|
||||
block_start = text.index("pattern-file advisory")
|
||||
block = text[block_start:block_start + 1200]
|
||||
assert 'acl_rules' in block
|
||||
assert 'use_backend_rules' in block
|
||||
assert 'request_headers' not in block.split("_pattern_paths")[1], (
|
||||
"advisory must not scan request_headers")
|
||||
|
||||
|
||||
def test_no_dash_f_reject_left_in_models():
|
||||
"""No model file may still hard-reject the `-f` flag."""
|
||||
for rel in ("models/frontend.py", "models/site_wizard.py"):
|
||||
text = (Path(__file__).resolve().parents[1] / rel).read_text()
|
||||
for m in re.finditer(r"-f\(\\s\|\$\)", text):
|
||||
ctx = text[max(0, m.start() - 400):m.start() + 400]
|
||||
assert "raise ValueError" not in ctx, (
|
||||
f"{rel}: a `-f` reject regex still sits next to a raise")
|
||||
@@ -206,41 +206,38 @@ def test_module_level_validate_haproxy_config_forwards_partial_fragment():
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Wizard Pydantic gate: ACL `-f` flag must be REJECTED at submit.
|
||||
# Issue #38 follow-up: ACL `-f <file>` pattern-file references are
|
||||
# ACCEPTED (the Bulgu #12 hard reject was removed — pattern files are
|
||||
# operator-managed host files, the agent's pre-reload `haproxy -c`
|
||||
# makes a missing file fail safely, and bulk import always accepted
|
||||
# `-f`). These tests pin the ACCEPT behaviour.
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_wizard_pydantic_rejects_acl_with_file_flag():
|
||||
"""Pre-fix the wizard's ACL string validator passed
|
||||
`acl name path -i -m reg -f /path` straight through. Apply-time
|
||||
HAProxy `-c` then failed with "failed to open pattern file".
|
||||
Pin that the validator now rejects `-f` at submit.
|
||||
def test_wizard_pydantic_accepts_acl_with_file_flag():
|
||||
"""Issue #38 follow-up — the wizard's ACL string validator must
|
||||
ACCEPT `-f <file>` pattern-file references (Bulgu #12 reject
|
||||
removed). Operators with large host-managed IP blacklists rely
|
||||
on this in production.
|
||||
"""
|
||||
from models.site_wizard import FrontendStep
|
||||
|
||||
# Minimal valid wizard frontend kwargs — only the offending
|
||||
# acl_rules entry should trigger the failure.
|
||||
fe_kwargs = dict(
|
||||
fe = FrontendStep(
|
||||
name="fe1",
|
||||
mode="http",
|
||||
bind_address="*",
|
||||
bind_port=80,
|
||||
acl_rules=["acl1 path -i -m reg -f /path"],
|
||||
)
|
||||
from pydantic import ValidationError
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
FrontendStep(**fe_kwargs)
|
||||
msg = str(exc_info.value)
|
||||
assert "-f" in msg or "pattern-file" in msg.lower(), (
|
||||
f"Bulgu #12 regression: ACL -f flag must be rejected with a "
|
||||
f"clear pattern-file error. Got: {msg}"
|
||||
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"], (
|
||||
"ACL `-f` rule must round-trip verbatim through the wizard model"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rule",
|
||||
[
|
||||
# Various spacing / position variants the regex must catch.
|
||||
# Various spacing / position variants must all be accepted.
|
||||
"acl1 path -f /etc/haproxy/list",
|
||||
"acl1 path -i -f /tmp/x.lst",
|
||||
"acl1 src -f /etc/haproxy/admins.lst",
|
||||
@@ -249,23 +246,19 @@ def test_wizard_pydantic_rejects_acl_with_file_flag():
|
||||
"acl1 path -f",
|
||||
],
|
||||
)
|
||||
def test_wizard_pydantic_rejects_acl_with_file_flag_variants(rule):
|
||||
"""Every spacing / position variant the operator might type must
|
||||
be rejected. Pinned defensively so the regex never accidentally
|
||||
relaxes to "only matches trailing -f".
|
||||
"""
|
||||
def test_wizard_pydantic_accepts_acl_with_file_flag_variants(rule):
|
||||
"""Every spacing / position variant must be accepted verbatim
|
||||
(Issue #38 follow-up — no `-f` shape may be rejected)."""
|
||||
from models.site_wizard import FrontendStep
|
||||
from pydantic import ValidationError
|
||||
|
||||
fe_kwargs = dict(
|
||||
fe = FrontendStep(
|
||||
name="fe1",
|
||||
mode="http",
|
||||
bind_address="*",
|
||||
bind_port=80,
|
||||
acl_rules=[rule],
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
FrontendStep(**fe_kwargs)
|
||||
assert fe.acl_rules == [rule]
|
||||
|
||||
|
||||
def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
|
||||
@@ -291,42 +284,29 @@ def test_wizard_pydantic_does_not_falsely_match_dash_f_inside_token():
|
||||
assert len(fe.acl_rules) == 3
|
||||
|
||||
|
||||
def test_manual_frontend_validator_rejects_acl_with_file_flag():
|
||||
"""Parity check: the manual Frontend API
|
||||
(`models/frontend.py::validate_acl_rules`) must apply the same
|
||||
`-f` rejection. Operators see consistent behaviour from both the
|
||||
wizard and the per-entity frontend page.
|
||||
def test_manual_frontend_validator_accepts_acl_with_file_flag():
|
||||
"""Parity check (Issue #38 follow-up): the manual Frontend API
|
||||
(`models/frontend.py::validate_acl_rules`) must ACCEPT `-f`
|
||||
pattern-file references, same as the wizard and bulk import.
|
||||
"""
|
||||
from models.frontend import FrontendConfig
|
||||
from pydantic import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
FrontendConfig(
|
||||
name="fe1",
|
||||
bind_port=80,
|
||||
mode="http",
|
||||
acl_rules=["acl1 path -i -m reg -f /path"],
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "-f" in msg or "pattern-file" in msg.lower(), (
|
||||
f"Manual frontend API parity regression: ACL -f flag must be "
|
||||
f"rejected. Got: {msg}"
|
||||
fe = FrontendConfig(
|
||||
name="fe1",
|
||||
bind_port=80,
|
||||
mode="http",
|
||||
acl_rules=["acl1 path -i -m reg -f /path"],
|
||||
)
|
||||
assert fe.acl_rules == ["acl1 path -i -m reg -f /path"]
|
||||
|
||||
|
||||
def test_wizard_pydantic_rejects_structured_redirect_dict_with_file_flag():
|
||||
"""Round-3 audit extension — structured redirect dicts (the
|
||||
alternative shape that `models/site_wizard.py::_validate_redirect_rules`
|
||||
accepts alongside legacy strings) also flow through to
|
||||
`services/haproxy_config.py::_format_redirect_rule` and emit
|
||||
their `condition` / `target` verbatim into the rendered HAProxy
|
||||
directive. Without the dict-aware reject the visual builder's
|
||||
`-f` block could be bypassed by hand-crafting a dict payload
|
||||
against the API — recreating the same `failed to open pattern
|
||||
file` failure at apply time.
|
||||
def test_wizard_pydantic_accepts_structured_redirect_dict_with_file_flag():
|
||||
"""Issue #38 follow-up — structured redirect dicts carrying `-f`
|
||||
pattern-file references in `condition`/`target` are ACCEPTED
|
||||
(the Bulgu #12 dict-aware reject was removed together with the
|
||||
string-rule reject).
|
||||
"""
|
||||
from models.site_wizard import FrontendStep, BackendStep
|
||||
from pydantic import ValidationError
|
||||
from models.site_wizard import FrontendStep
|
||||
|
||||
fe_kwargs = dict(
|
||||
name="fe1",
|
||||
@@ -334,37 +314,32 @@ def test_wizard_pydantic_rejects_structured_redirect_dict_with_file_flag():
|
||||
mode="http",
|
||||
)
|
||||
|
||||
# `condition` carrying `-f` must be rejected.
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
FrontendStep(
|
||||
**fe_kwargs,
|
||||
redirect_rules=[
|
||||
{
|
||||
"type": "scheme",
|
||||
"target": "https",
|
||||
"condition": "if { src -f /etc/haproxy/admins.lst }",
|
||||
}
|
||||
],
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "pattern-file" in msg.lower() or "-f" in msg, msg
|
||||
# `condition` carrying `-f` is accepted.
|
||||
fe = FrontendStep(
|
||||
**fe_kwargs,
|
||||
redirect_rules=[
|
||||
{
|
||||
"type": "scheme",
|
||||
"target": "https",
|
||||
"condition": "if { src -f /etc/haproxy/admins.lst }",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert fe.redirect_rules[0]["condition"] == "if { src -f /etc/haproxy/admins.lst }"
|
||||
|
||||
# `target` carrying `-f` must also be rejected (defence-in-depth
|
||||
# for hand-crafted payloads).
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
FrontendStep(
|
||||
**fe_kwargs,
|
||||
redirect_rules=[
|
||||
{
|
||||
"type": "location",
|
||||
"target": "/foo -f /tmp/x.lst",
|
||||
}
|
||||
],
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "pattern-file" in msg.lower() or "-f" in msg, msg
|
||||
# `target` carrying `-f` is accepted too.
|
||||
fe = FrontendStep(
|
||||
**fe_kwargs,
|
||||
redirect_rules=[
|
||||
{
|
||||
"type": "location",
|
||||
"target": "/foo -f /tmp/x.lst",
|
||||
}
|
||||
],
|
||||
)
|
||||
assert fe.redirect_rules[0]["target"] == "/foo -f /tmp/x.lst"
|
||||
|
||||
# Clean structured dict still passes — no false positive.
|
||||
# Clean structured dict still passes.
|
||||
FrontendStep(
|
||||
**fe_kwargs,
|
||||
redirect_rules=[
|
||||
@@ -667,8 +642,8 @@ def test_user_reported_wizard_config_emits_no_false_warnings():
|
||||
zero WARNINGs from the directives we expanded.
|
||||
"""
|
||||
# Distilled from the user's bulk-site-create snapshot, minus the
|
||||
# `-f` ACL (which the new Pydantic gate rejects before this
|
||||
# validator ever runs).
|
||||
# `-f` ACL (accepted since the Issue #38 follow-up, but irrelevant
|
||||
# to the directive-expansion warnings this test pins).
|
||||
config = """# ─── Wizard candidate fragment (dry-run preview) ───
|
||||
frontend fe-site1
|
||||
bind *:80
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Regression tests for the 2026-07 security advisories.
|
||||
|
||||
Covers:
|
||||
- GHSA-7rhv-c5pc-69r8 (CRITICAL RCE): agent script-template management must
|
||||
require the agents.version permission, not merely authentication.
|
||||
- GHSA-3p5c-m5m4-mjpx (missing auth): agent data-plane endpoints must require a
|
||||
valid X-API-Key, and operator/UI endpoints must require a JWT. An anonymous
|
||||
caller must never get a 200 with sensitive data.
|
||||
|
||||
These are behavioral assertions via FastAPI's TestClient. The auth checks were
|
||||
deliberately moved ahead of any DB access, so an unauthenticated request is
|
||||
rejected without needing a database — the same approach as the existing
|
||||
test_ssl_list_endpoint_auth.py. Accepted rejection statuses are 401/403/422
|
||||
(never 200-with-data).
|
||||
"""
|
||||
import re
|
||||
import os
|
||||
import pytest
|
||||
|
||||
REJECT = (401, 403, 422)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GHSA-3p5c: agent data-plane endpoints must reject a missing X-API-Key
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_agent_config_requires_api_key(client):
|
||||
"""GET /api/agents/{name}/config leaked the full haproxy.cfg without a key."""
|
||||
res = client.get("/api/agents/prod-haproxy-1/config")
|
||||
assert res.status_code in REJECT, (
|
||||
f"GHSA-3p5c regression: agent config served without X-API-Key ({res.status_code})"
|
||||
)
|
||||
|
||||
|
||||
def test_agent_ssl_certificates_requires_api_key(client):
|
||||
"""GET /api/agents/{name}/ssl-certificates leaked SSL private keys without a key."""
|
||||
res = client.get("/api/agents/prod-haproxy-1/ssl-certificates")
|
||||
assert res.status_code in REJECT, (
|
||||
f"GHSA-3p5c regression: SSL certs (private keys!) served without X-API-Key ({res.status_code})"
|
||||
)
|
||||
if res.status_code == 200:
|
||||
assert "private_key_content" not in res.text
|
||||
|
||||
|
||||
def test_agent_upgrade_status_requires_api_key(client):
|
||||
res = client.get("/api/agents/prod-haproxy-1/upgrade-status")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
def test_agent_pending_requests_requires_api_key(client):
|
||||
res = client.get("/api/configuration/agents/prod-haproxy-1/pending-requests")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
def test_agent_heartbeat_by_name_requires_api_key(client):
|
||||
res = client.post("/api/agents/heartbeat", json={"name": "rogue-poc"})
|
||||
assert res.status_code in REJECT, (
|
||||
f"GHSA-3p5c regression: keyless heartbeat/auto-register accepted ({res.status_code})"
|
||||
)
|
||||
|
||||
|
||||
def test_agent_heartbeat_by_id_requires_api_key(client):
|
||||
res = client.post("/api/agents/1/heartbeat", json={"name": "spoofed"})
|
||||
assert res.status_code in REJECT, (
|
||||
f"GHSA-3p5c regression: keyless by-id heartbeat state-spoof accepted ({res.status_code})"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GHSA-3p5c: operator/UI endpoints must reject a missing JWT
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_agents_inventory_requires_jwt(client):
|
||||
"""GET /api/agents (RCE read-back channel) was served without a JWT."""
|
||||
res = client.get("/api/agents")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/api/health/deep", "/api/health/agents", "/api/health/clusters"])
|
||||
def test_detailed_health_requires_jwt(client, path):
|
||||
res = client.get(path)
|
||||
assert res.status_code in REJECT, f"{path} served without a JWT ({res.status_code})"
|
||||
|
||||
|
||||
def test_simple_health_stays_public(client):
|
||||
"""The liveness probe endpoint (/api/health) must remain UNAUTHENTICATED.
|
||||
|
||||
It reports 200 when healthy and 503 when the DB is unreachable (as in this
|
||||
no-DB test env); what matters for the k8s probe is that it is never gated
|
||||
behind auth (401/403). We only added auth to /api/health/{deep,agents,clusters}.
|
||||
"""
|
||||
res = client.get("/api/health")
|
||||
assert res.status_code not in (401, 403), (
|
||||
f"Regression: /api/health liveness probe now requires auth ({res.status_code}) — "
|
||||
f"this breaks k8s liveness/readiness"
|
||||
)
|
||||
|
||||
|
||||
def test_dashboard_stats_requires_jwt(client):
|
||||
res = client.get("/api/dashboard-stats/stats?cluster_id=1")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
def test_ssl_config_versions_requires_jwt(client):
|
||||
res = client.get("/api/ssl/certificates/1/config-versions")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GHSA-7rhv (CRITICAL RCE): script-template management
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_script_template_write_requires_auth(client):
|
||||
"""Anonymous POST must be rejected outright."""
|
||||
res = client.post("/api/agents/script-templates/linux",
|
||||
json={"script_content": "#!/bin/bash\nid", "version": "9.9.9"})
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
def test_script_template_read_requires_auth(client):
|
||||
res = client.get("/api/agents/script-templates/linux")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GHSA-3p5c (round 2): sibling endpoints exposing the SAME class of data
|
||||
# (found during post-merge review — must also require a JWT)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/haproxy-cluster-pools/1/agents", # full agent inventory — same class as GET /api/agents
|
||||
"/api/pools",
|
||||
"/api/haproxy-cluster-pools",
|
||||
"/api/dashboard/stats",
|
||||
"/api/dashboard/overview", # optional-auth pattern — leaked stats/names/alerts anonymously
|
||||
"/api/haproxy/stats",
|
||||
"/api/waf/rules",
|
||||
"/api/health/errors",
|
||||
])
|
||||
def test_sibling_inventory_endpoints_require_jwt(client, path):
|
||||
res = client.get(path)
|
||||
assert res.status_code in REJECT, (
|
||||
f"GHSA-3p5c (round 2) regression: {path} served without a JWT ({res.status_code}) — "
|
||||
f"anonymous access to inventory/topology/WAF/error data"
|
||||
)
|
||||
|
||||
|
||||
def test_pool_agents_no_anonymous_inventory_leak(client):
|
||||
"""The richest bypass: /api/haproxy-cluster-pools/{id}/agents must not leak inventory."""
|
||||
res = client.get("/api/haproxy-cluster-pools/1/agents")
|
||||
assert res.status_code in REJECT
|
||||
if res.status_code == 200:
|
||||
assert "ip_address" not in res.text and "hostname" not in res.text
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GHSA-3p5c (round 2): agent webhooks must return 401 (not a 200 error body)
|
||||
# for anonymous callers — the auth raise must propagate, not be swallowed.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/agents/some-agent/config-applied",
|
||||
"/api/agents/some-agent/config-validation-failed",
|
||||
"/api/agents/some-agent/config-sync",
|
||||
])
|
||||
def test_agent_webhooks_reject_anonymous_with_401(client, path):
|
||||
res = client.post(path, json={})
|
||||
assert res.status_code in REJECT, (
|
||||
f"{path} returned {res.status_code} for an anonymous caller — the auth "
|
||||
f"rejection must be a 401/403, not a swallowed 200 error body"
|
||||
)
|
||||
# Specifically must NOT be a 200 "status: error" body.
|
||||
assert res.status_code != 200
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# GHSA-3p5c (round 2): GET /api/agents must accept EITHER a JWT OR an agent
|
||||
# X-API-Key. Anonymous (neither) is still rejected — agents send a key, so a
|
||||
# JWT-only gate would break them (verified end-to-end in the localtest smoke).
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def test_agents_inventory_still_rejects_fully_anonymous(client):
|
||||
"""No JWT and no X-API-Key -> 401 (the agent-key accept path needs a valid key)."""
|
||||
res = client.get("/api/agents")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
def test_generate_uninstall_script_requires_auth(client):
|
||||
"""Agent-management endpoint must not be anonymously reachable (JWT or agent key)."""
|
||||
res = client.get("/api/agents/generate-uninstall-script/linux")
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [
|
||||
"/api/config/validate",
|
||||
"/api/config/optimize",
|
||||
"/api/config/templates/default/generate",
|
||||
])
|
||||
def test_config_compute_endpoints_require_auth(client, path):
|
||||
"""Config compute endpoints (run a HAProxy validator on caller input) were
|
||||
optional-auth; now require a JWT. The dependency rejects before body parsing."""
|
||||
res = client.post(path, json={})
|
||||
assert res.status_code in REJECT
|
||||
|
||||
|
||||
def test_script_template_write_enforces_agents_version_permission():
|
||||
"""Static guarantee: the write handler checks agents.version (not just authN).
|
||||
|
||||
A behavioral 403-for-viewer test would need a seeded DB + a minted viewer JWT;
|
||||
instead we assert the permission gate is present in source, mirroring the
|
||||
existing audit-style source tests. This is the core RCE fix (GHSA-7rhv).
|
||||
"""
|
||||
src_path = os.path.join(os.path.dirname(__file__), "..", "routers", "agent.py")
|
||||
with open(src_path, "r") as f:
|
||||
src = f.read()
|
||||
# Isolate the save_agent_script_template handler body.
|
||||
m = re.search(r"async def save_agent_script_template\(.*?\n(.*?)\n@router\.", src, re.DOTALL)
|
||||
assert m, "save_agent_script_template handler not found"
|
||||
body = m.group(1)
|
||||
assert 'check_user_permission' in body and '"agents", "version"' in body, (
|
||||
"GHSA-7rhv regression: script-template WRITE no longer enforces the "
|
||||
"agents.version permission — any JWT holder could poison the root install script"
|
||||
)
|
||||
@@ -276,14 +276,16 @@ def test_categorize_routes_directives_correctly():
|
||||
|
||||
def test_emit_buckets_flushed_in_canonical_order():
|
||||
"""The flush block at end of frontend processing must list buckets
|
||||
in: prelude → stick → tcp_req → acl → http_req → http_resp →
|
||||
in: prelude → filter → stick → tcp_req → acl → http_req → http_resp →
|
||||
redirect → use_be → default_be. Pre-fix `http-request` rules
|
||||
interleaved with `use_backend` rules in source order, producing
|
||||
HAProxy parser warnings."""
|
||||
HAProxy parser warnings. (Issue #38 added the `filter` bucket, flushed
|
||||
right after `prelude` so SPOE `filter` lines precede `send-spoe-group`.)"""
|
||||
src = _gen_src()
|
||||
flush_match = re.search(
|
||||
r'for\s+_bucket_key\s+in\s+\(\s*'
|
||||
r'"prelude"\s*,\s*'
|
||||
r'"filter"\s*,\s*'
|
||||
r'"stick"\s*,\s*'
|
||||
r'"tcp_req"\s*,\s*'
|
||||
r'"acl"\s*,\s*'
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Issue #38 regression tests: HAProxy SPOE `filter` + frontend `log-format` support.
|
||||
|
||||
Bug: the bulk-config parser recognised only a fixed set of frontend directives,
|
||||
so `filter spoe engine coraza config ...` and `log-format ...` were silently
|
||||
dropped on import / manual edit. This regenerated a config missing the SPOE
|
||||
engine definition, so HAProxy failed with
|
||||
"unable to find SPOE engine 'coraza' used by the send-spoe-group 'coraza-req'".
|
||||
|
||||
These tests verify the end-to-end fix without requiring a database:
|
||||
1. parser captures `filter` + `log-format` into the new ParsedFrontend fields;
|
||||
2. `http-request send-spoe-group` is still preserved (regression guard);
|
||||
3. the generator's directive categoriser + bucket flush order emit `filter`
|
||||
BEFORE the `http-request send-spoe-group` rules and keep `log-format`;
|
||||
4. reject/rollback restores the new columns;
|
||||
5. a non-SPOE frontend is completely unaffected (zero-impact).
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.haproxy_config_parser import parse_haproxy_config, ParsedFrontend
|
||||
from services.haproxy_config import _categorize_haproxy_directive
|
||||
from models.frontend import FrontendConfig
|
||||
|
||||
|
||||
# The exact frontend/backend config reported in Issue #38 (Coraza-SPOA).
|
||||
ISSUE_38_CONFIG = r"""
|
||||
frontend web-frontend
|
||||
bind *:8073
|
||||
mode http
|
||||
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %ST\ %B\ %{+Q}r\ %[var(txn.coraza.id)]\ waf-hit:\ %[var(txn.coraza.fail)]"
|
||||
filter spoe engine coraza config /etc/haproxy/coraza.cfg
|
||||
http-request set-var(txn.coraza.app) str(haproxy_waf)
|
||||
http-request send-spoe-group coraza coraza-req
|
||||
http-request deny if { var(txn.coraza.fail) -m int eq 1 }
|
||||
default_backend web-backend
|
||||
|
||||
backend web-backend
|
||||
balance roundrobin
|
||||
mode http
|
||||
server server1 192.168.1.10:443 weight 100 ssl verify none
|
||||
|
||||
backend coraza-spoa
|
||||
mode tcp
|
||||
option spop-check
|
||||
server coraza_spoa 192.168.12.21:9000
|
||||
"""
|
||||
|
||||
|
||||
def _get_frontend(parse_result, name):
|
||||
for fe in parse_result.frontends:
|
||||
if fe.name == name:
|
||||
return fe
|
||||
return None
|
||||
|
||||
|
||||
class TestParserCapturesSpoe:
|
||||
def test_filter_and_log_format_captured(self):
|
||||
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||
fe = _get_frontend(result, "web-frontend")
|
||||
assert fe is not None, "web-frontend should be parsed and kept"
|
||||
assert fe.filters is not None
|
||||
assert "filter spoe engine coraza config /etc/haproxy/coraza.cfg" in fe.filters
|
||||
assert fe.log_format is not None
|
||||
assert fe.log_format.startswith("log-format")
|
||||
# the escaped/quoted format string must be preserved verbatim
|
||||
assert "%[var(txn.coraza.fail)]" in fe.log_format
|
||||
|
||||
def test_send_spoe_group_still_preserved(self):
|
||||
# Regression guard: http-request rules (incl. send-spoe-group) must
|
||||
# still be collected into request_headers as before.
|
||||
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||
fe = _get_frontend(result, "web-frontend")
|
||||
assert fe.request_headers is not None
|
||||
assert "send-spoe-group coraza coraza-req" in fe.request_headers
|
||||
|
||||
def test_multiple_filters_preserved_in_order(self):
|
||||
cfg = """
|
||||
frontend f1
|
||||
bind *:80
|
||||
mode http
|
||||
filter compression
|
||||
filter spoe engine coraza config /etc/haproxy/coraza.cfg
|
||||
default_backend b1
|
||||
|
||||
backend b1
|
||||
mode http
|
||||
server s1 10.0.0.1:80
|
||||
"""
|
||||
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
|
||||
lines = fe.filters.split("\n")
|
||||
assert lines == [
|
||||
"filter compression",
|
||||
"filter spoe engine coraza config /etc/haproxy/coraza.cfg",
|
||||
]
|
||||
|
||||
def test_log_format_sd_variant_captured(self):
|
||||
cfg = """
|
||||
frontend f1
|
||||
bind *:80
|
||||
mode http
|
||||
log-format-sd "[exampleSDID@1234 field=value]"
|
||||
default_backend b1
|
||||
|
||||
backend b1
|
||||
mode http
|
||||
server s1 10.0.0.1:80
|
||||
"""
|
||||
fe = _get_frontend(parse_haproxy_config(cfg), "f1")
|
||||
assert fe.log_format is not None
|
||||
assert fe.log_format.startswith("log-format-sd")
|
||||
|
||||
|
||||
class TestGeneratorOrderingContract:
|
||||
"""The generator routes directives into ordered buckets. Verify SPOE
|
||||
correctness at the (pure) categoriser + documented flush-order level."""
|
||||
|
||||
def test_filter_routes_to_filter_bucket(self):
|
||||
assert _categorize_haproxy_directive(" filter spoe engine coraza config /x.cfg") == "filter"
|
||||
|
||||
def test_send_spoe_group_routes_to_http_req(self):
|
||||
assert _categorize_haproxy_directive(" http-request send-spoe-group coraza coraza-req") == "http_req"
|
||||
|
||||
def test_log_format_routes_to_prelude(self):
|
||||
assert _categorize_haproxy_directive(' log-format "%ci:%cp"') == "prelude"
|
||||
assert _categorize_haproxy_directive(' log-format-sd "[x]"') == "prelude"
|
||||
|
||||
def test_flush_order_places_filter_before_http_req(self):
|
||||
# The bucket flush order is the single source of truth for emission
|
||||
# ordering. Assert `filter` is flushed before `http_req` (and after
|
||||
# `prelude`), guaranteeing `filter ...` renders before
|
||||
# `http-request send-spoe-group ...`.
|
||||
src = _read_source("services/haproxy_config.py")
|
||||
m = re.search(r"for _bucket_key in \((.*?)\):", src, re.DOTALL)
|
||||
assert m, "bucket flush loop not found"
|
||||
order = re.findall(r'"(\w+)"', m.group(1))
|
||||
assert "filter" in order, "new 'filter' bucket missing from flush order"
|
||||
assert order.index("prelude") < order.index("filter") < order.index("http_req")
|
||||
|
||||
|
||||
class TestModelAndRollback:
|
||||
def test_model_has_passthrough_fields(self):
|
||||
fc = FrontendConfig(
|
||||
name="f", bind_port=80,
|
||||
filters="filter spoe engine coraza config /etc/haproxy/coraza.cfg",
|
||||
log_format='log-format "%ci"',
|
||||
)
|
||||
assert fc.filters.startswith("filter spoe")
|
||||
assert fc.log_format.startswith("log-format")
|
||||
|
||||
def test_dataclass_defaults_none(self):
|
||||
fe = ParsedFrontend(name="f")
|
||||
assert fe.filters is None
|
||||
assert fe.log_format is None
|
||||
|
||||
def test_rollback_restores_new_columns(self):
|
||||
# Reject/rollback of a frontend UPDATE must restore the new columns,
|
||||
# otherwise the rejected (new) filters/log_format would persist.
|
||||
src = _read_source("utils/entity_snapshot.py")
|
||||
assert "log_format = $" in src
|
||||
assert "filters = $" in src
|
||||
assert "old_values.get('log_format')" in src
|
||||
assert "old_values.get('filters')" in src
|
||||
|
||||
|
||||
class TestZeroImpact:
|
||||
def test_non_spoe_frontend_unaffected(self):
|
||||
cfg = """
|
||||
frontend plain
|
||||
bind *:80
|
||||
mode http
|
||||
option httplog
|
||||
default_backend b1
|
||||
|
||||
backend b1
|
||||
mode http
|
||||
server s1 10.0.0.1:80
|
||||
"""
|
||||
fe = _get_frontend(parse_haproxy_config(cfg), "plain")
|
||||
# No filter / log-format present → new fields stay None (no behaviour change)
|
||||
assert fe.filters is None
|
||||
assert fe.log_format is None
|
||||
|
||||
def test_spop_check_backend_roundtrips_without_warning(self):
|
||||
result = parse_haproxy_config(ISSUE_38_CONFIG)
|
||||
be = next((b for b in result.backends if b.name == "coraza-spoa"), None)
|
||||
assert be is not None, "coraza-spoa backend should import"
|
||||
assert be.mode == "tcp"
|
||||
assert be.options and "option spop-check" in be.options
|
||||
# spop-check is now a known option → no spurious 'unknown option' warning
|
||||
assert not any(
|
||||
"coraza-spoa" in w and "spop-check" in w and "Unknown" in w
|
||||
for w in result.warnings
|
||||
)
|
||||
|
||||
|
||||
def _read_source(relpath):
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
with open(os.path.join(base, relpath), "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Unit tests for the SSRF guard (GHSA-3vh4-gvxx-wm2p).
|
||||
|
||||
The guard protects server-side fetches of ACME `directory_url` values. This
|
||||
project uses only public ACME CAs, so every non-public IP must be rejected.
|
||||
Tests avoid real network/DNS by using IP literals and scheme checks.
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from utils.ssrf_guard import is_public_ip, assert_public_url, SSRFValidationError
|
||||
|
||||
|
||||
# ---- is_public_ip -----------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("ip", [
|
||||
"8.8.8.8", "1.1.1.1", "93.184.216.34", # public
|
||||
])
|
||||
def test_public_ips_allowed(ip):
|
||||
assert is_public_ip(ip) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip", [
|
||||
"127.0.0.1", # loopback
|
||||
"10.0.0.5", # RFC1918
|
||||
"172.19.0.1", # RFC1918 (the SSRF PoC docker gateway)
|
||||
"192.168.1.1", # RFC1918
|
||||
"169.254.169.254", # link-local / cloud metadata
|
||||
"0.0.0.0", # unspecified
|
||||
"::1", # IPv6 loopback
|
||||
"fe80::1", # IPv6 link-local
|
||||
"::ffff:127.0.0.1", # IPv4-mapped IPv6 loopback (R18c bypass)
|
||||
"::ffff:169.254.169.254", # IPv4-mapped metadata
|
||||
"not-an-ip", # garbage
|
||||
])
|
||||
def test_non_public_ips_rejected(ip):
|
||||
assert is_public_ip(ip) is False
|
||||
|
||||
|
||||
# ---- assert_public_url ------------------------------------------------------
|
||||
|
||||
def _raises(url):
|
||||
with pytest.raises(SSRFValidationError):
|
||||
asyncio.run(assert_public_url(url))
|
||||
|
||||
|
||||
def test_rejects_non_https_scheme():
|
||||
# The SSRF PoC used http:// against an internal listener.
|
||||
_raises("http://172.19.0.1:2121/internal-secret")
|
||||
_raises("http://8.8.8.8/") # even a public IP over http is refused
|
||||
_raises("file:///etc/passwd")
|
||||
_raises("gopher://8.8.8.8/")
|
||||
|
||||
|
||||
def test_rejects_private_ip_literals():
|
||||
_raises("https://127.0.0.1/")
|
||||
_raises("https://10.0.0.5/")
|
||||
_raises("https://169.254.169.254/latest/meta-data/")
|
||||
_raises("https://[::1]/")
|
||||
|
||||
|
||||
def test_rejects_empty_or_hostless():
|
||||
_raises("")
|
||||
_raises("https://")
|
||||
|
||||
|
||||
def test_allows_public_ip_literal_https():
|
||||
# A public IP literal over https must pass (no DNS needed).
|
||||
asyncio.run(assert_public_url("https://8.8.8.8/directory"))
|
||||
@@ -329,9 +329,10 @@ async def _rollback_update(
|
||||
tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28,
|
||||
rate_limit = $29, compression = $30, log_separate = $31,
|
||||
monitor_uri = $32, maxconn = $33,
|
||||
cluster_id = $34, is_active = $35, last_config_status = $36,
|
||||
cluster_id = $34, is_active = $35, last_config_status = $36,
|
||||
log_format = $37, filters = $38,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $37
|
||||
WHERE id = $39
|
||||
""",
|
||||
old_values.get('name'),
|
||||
old_values.get('bind_address'),
|
||||
@@ -369,6 +370,8 @@ async def _rollback_update(
|
||||
old_values.get('cluster_id'),
|
||||
old_values.get('is_active'),
|
||||
old_values.get('last_config_status'),
|
||||
old_values.get('log_format'), # Issue #38
|
||||
old_values.get('filters'), # Issue #38
|
||||
entity_id
|
||||
)
|
||||
|
||||
|
||||
@@ -105,6 +105,11 @@ class ParsedFrontend:
|
||||
response_headers: Optional[str] = None
|
||||
options: Optional[str] = None # HAProxy frontend options (option httplog, option forwardfor, etc.)
|
||||
tcp_request_rules: Optional[str] = None # TCP request directives (for TCP mode)
|
||||
# Issue #38: SPOE (and other) filter directives + frontend log-format.
|
||||
# Stored as full directive lines; `filters` is newline-joined to preserve
|
||||
# ordering when multiple `filter ...` lines exist.
|
||||
log_format: Optional[str] = None # `log-format` / `log-format-sd` line(s)
|
||||
filters: Optional[str] = None # `filter ...` line(s), e.g. `filter spoe engine coraza config ...`
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -256,8 +261,23 @@ class HAProxyConfigParser:
|
||||
acl_rules_list = []
|
||||
use_backend_rules_list = []
|
||||
tcp_request_rules_list = []
|
||||
filters_list = []
|
||||
log_format_list = []
|
||||
|
||||
for line in lines:
|
||||
# Issue #38: capture `filter ...` (SPOE/Coraza etc.) and
|
||||
# `log-format`/`log-format-sd` directives. Pre-fix these matched
|
||||
# no branch below and were silently dropped, so an imported SPOE
|
||||
# config lost `filter spoe engine coraza ...` (→ HAProxy fatal
|
||||
# "unable to find SPOE engine") and the frontend log-format.
|
||||
# `continue` isolates them from the header/option handling below.
|
||||
if line.startswith('filter '):
|
||||
filters_list.append(line.strip())
|
||||
continue
|
||||
if re.match(r'^log-format(-sd)?\s', line, re.IGNORECASE):
|
||||
log_format_list.append(line.strip())
|
||||
continue
|
||||
|
||||
# Parse bind directive
|
||||
# IMPORTANT: Handle multiple bind lines correctly
|
||||
# Example: bind *:1002 (HTTP) and bind *:443 ssl (HTTPS)
|
||||
@@ -540,6 +560,13 @@ class HAProxyConfigParser:
|
||||
if tcp_request_rules_list:
|
||||
frontend.tcp_request_rules = '\n'.join(tcp_request_rules_list)
|
||||
|
||||
# Issue #38: assign captured SPOE filters + log-format
|
||||
if filters_list:
|
||||
frontend.filters = '\n'.join(filters_list)
|
||||
|
||||
if log_format_list:
|
||||
frontend.log_format = '\n'.join(log_format_list)
|
||||
|
||||
self.frontends.append(frontend)
|
||||
logger.info(f"Parsed frontend: {name} -> {frontend.default_backend}")
|
||||
|
||||
@@ -666,9 +693,14 @@ class HAProxyConfigParser:
|
||||
'transparent', 'abortonclose', 'allbackups', 'checkcache', 'clitcpka',
|
||||
'srvtcpka', 'http-no-delay', 'socket-stats', 'tcp-smart-accept',
|
||||
'tcp-smart-connect', 'independant-streams', 'log-separate-errors',
|
||||
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response'
|
||||
'log-health-checks', 'accept-invalid-http-request', 'accept-invalid-http-response',
|
||||
# Issue #38: SPOP health check for SPOE agent backends
|
||||
# (e.g. coraza-spoa). Already collected below regardless, but
|
||||
# listing it suppresses the spurious "unknown option" warning
|
||||
# for the exact SPOE use-case.
|
||||
'spop-check'
|
||||
]
|
||||
|
||||
|
||||
if option_name not in valid_options:
|
||||
# Unknown/invalid option - add warning but still collect it
|
||||
self.warnings.append(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
SSRF guard for outbound HTTP fetches to user/DB-controlled URLs.
|
||||
|
||||
GHSA-3vh4-gvxx-wm2p: the ACME `directory_url` was fetched server-side with no
|
||||
validation, turning the backend into a request-forwarding primitive against
|
||||
loopback / RFC1918 / link-local / cloud-metadata IP space (and reflecting the
|
||||
upstream JSON keys back to the caller).
|
||||
|
||||
The classification logic mirrors the hardened ACME diagnostics probe
|
||||
(services/acme_diagnostics.py, R18b/R18c audits): unwrap IPv4-mapped IPv6, reject
|
||||
loopback/link-local/private/multicast/reserved/unspecified, resolve DNS off the
|
||||
event loop, and pin the aiohttp connector to IPv4 so the family the guard
|
||||
classifies equals the family the connector dials (no dual-stack AAAA bypass).
|
||||
|
||||
Deployment note: this project uses ONLY public ACME CAs (e.g. Let's Encrypt), so
|
||||
every non-public IP is rejected — there is no internal/private-IP CA to allow.
|
||||
Residual: DNS rebinding between validate-time and fetch-time is not fully closed
|
||||
(fetching by hostname keeps TLS cert validation working); the IPv4 pin +
|
||||
https-only + admin-gating + internal-only exposure keep this residual low.
|
||||
"""
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
|
||||
# Only https is legitimate for a public ACME directory URL.
|
||||
_ALLOWED_SCHEMES = {"https"}
|
||||
|
||||
|
||||
class SSRFValidationError(ValueError):
|
||||
"""Raised when a URL fails SSRF validation (bad scheme or non-public host)."""
|
||||
|
||||
|
||||
def is_public_ip(ip_str: str) -> bool:
|
||||
"""Return True only for globally-routable IPv4/IPv6 addresses.
|
||||
|
||||
Unwraps IPv4-mapped IPv6 (``::ffff:127.0.0.1``) before classification so an
|
||||
attacker-controlled AAAA record cannot smuggle loopback/metadata through the
|
||||
IPv6 checks.
|
||||
"""
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
if ip.is_loopback or ip.is_link_local or ip.is_private:
|
||||
return False
|
||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _resolve_ips(host: str, *, timeout: float = 5.0) -> List[str]:
|
||||
"""Resolve `host` to IPv4 addresses without blocking the event loop."""
|
||||
loop = asyncio.get_running_loop()
|
||||
_, _, ips = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, socket.gethostbyname_ex, host),
|
||||
timeout=timeout,
|
||||
)
|
||||
return ips or []
|
||||
|
||||
|
||||
async def assert_public_url(url: str, *, timeout: float = 5.0) -> None:
|
||||
"""Validate that `url` is safe to fetch server-side.
|
||||
|
||||
Requirements: https scheme, and a host that either is a public IP literal or
|
||||
resolves entirely to public IPv4 addresses. Raises ``SSRFValidationError``
|
||||
otherwise. Intended to be called immediately before the outbound request,
|
||||
which MUST use ``safe_connector()`` and ``allow_redirects=False``.
|
||||
"""
|
||||
if not url or not isinstance(url, str):
|
||||
raise SSRFValidationError("A URL is required")
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme.lower() not in _ALLOWED_SCHEMES:
|
||||
raise SSRFValidationError(f"URL scheme must be https (got '{parsed.scheme or 'none'}')")
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise SSRFValidationError("URL has no host")
|
||||
|
||||
# Literal IP host: classify directly, no DNS needed.
|
||||
try:
|
||||
ipaddress.ip_address(host)
|
||||
if not is_public_ip(host):
|
||||
raise SSRFValidationError(f"URL host {host} is not a public IP address")
|
||||
return
|
||||
except ValueError:
|
||||
pass # hostname, not an IP literal -> resolve below
|
||||
|
||||
try:
|
||||
ips = await _resolve_ips(host, timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
raise SSRFValidationError(f"DNS resolution timed out for {host}")
|
||||
except Exception as e: # socket.gaierror etc.
|
||||
raise SSRFValidationError(f"DNS resolution failed for {host}: {e}")
|
||||
|
||||
if not ips:
|
||||
raise SSRFValidationError(f"{host} did not resolve to any address")
|
||||
if not all(is_public_ip(ip) for ip in ips):
|
||||
raise SSRFValidationError(
|
||||
f"{host} resolves to a non-public IP {ips} — refusing to fetch (SSRF guard)"
|
||||
)
|
||||
|
||||
|
||||
def safe_connector() -> aiohttp.TCPConnector:
|
||||
"""IPv4-pinned aiohttp connector.
|
||||
|
||||
Forces the connect family to match what :func:`assert_public_url` classified
|
||||
(closes the dual-stack AAAA bypass). TLS verification stays ON (default), so
|
||||
the request must target the validated hostname. Always combine with
|
||||
``allow_redirects=False`` at the request call site.
|
||||
"""
|
||||
return aiohttp.TCPConnector(family=socket.AF_INET)
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.8.7",
|
||||
"releaseName": "Version reporting single-source fix",
|
||||
"releaseDate": "2026-07-09"
|
||||
"version": "1.8.10",
|
||||
"releaseName": "Security hardening — RCE, missing-auth and SSRF advisories (GHSA-7rhv/3p5c/3vh4)",
|
||||
"releaseDate": "2026-07-20"
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ services:
|
||||
|
||||
# Redis Cache
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: redis:8.8.0-alpine
|
||||
container_name: haproxy-openmanager-redis
|
||||
command: redis-server --maxmemory 2gb --maxmemory-policy volatile-lru --save ""
|
||||
ports:
|
||||
|
||||
Generated
+181
-145
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.7.8",
|
||||
"version": "1.8.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.7.8",
|
||||
"version": "1.8.10",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
@@ -20,7 +20,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-window": "^1.8.10",
|
||||
"react18-json-view": "^0.2.9",
|
||||
"recharts": "^2.5.0"
|
||||
@@ -179,18 +179,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
|
||||
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/generator": "^7.29.6",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/helpers": "^7.29.2",
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
@@ -239,14 +239,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -472,9 +472,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -482,9 +482,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -531,13 +531,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
|
||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -2274,14 +2274,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2669,10 +2669,20 @@
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -2756,6 +2766,20 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@istanbuljs/schema": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
|
||||
@@ -4260,9 +4284,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -4654,6 +4678,27 @@
|
||||
"url": "https://github.com/sponsors/gregberge"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.3.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"picocolors": "1.1.1",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/jest-dom": {
|
||||
"version": "5.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.17.0.tgz",
|
||||
@@ -6534,6 +6579,22 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios/node_modules/form-data": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -9779,10 +9840,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -10560,22 +10631,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -11082,9 +11137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -11344,9 +11399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"version": "2.0.9",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
|
||||
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
|
||||
"version": "2.0.10",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15165,20 +15220,6 @@
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "16.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz",
|
||||
@@ -15227,16 +15268,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/form-data": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz",
|
||||
"integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==",
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
|
||||
"integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15431,14 +15472,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/launch-editor": {
|
||||
"version": "2.13.2",
|
||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz",
|
||||
"integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==",
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
|
||||
"integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.1.1",
|
||||
"shell-quote": "^1.8.3"
|
||||
"shell-quote": "^1.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
@@ -16685,9 +16726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -19155,12 +19196,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
"@remix-run/router": "1.23.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -19170,13 +19211,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -19667,32 +19708,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url-loader": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-4.0.0.tgz",
|
||||
"integrity": "sha512-05VEMczVREcbtT7Bz+C+96eUO5HDNvdthIiMB34t7FcF8ehcu4wC0sSgPUubs3XW2Q3CNLJk/BJrCU9wVRymiA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz",
|
||||
"integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"adjust-sourcemap-loader": "^4.0.0",
|
||||
"convert-source-map": "^1.7.0",
|
||||
"loader-utils": "^2.0.0",
|
||||
"postcss": "^7.0.35",
|
||||
"postcss": "^8.2.14",
|
||||
"source-map": "0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rework": "1.0.1",
|
||||
"rework-visit": "1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rework": {
|
||||
"optional": true
|
||||
},
|
||||
"rework-visit": {
|
||||
"optional": true
|
||||
}
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url-loader/node_modules/convert-source-map": {
|
||||
@@ -19702,31 +19731,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-url-loader/node_modules/picocolors": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-0.2.1.tgz",
|
||||
"integrity": "sha512-cMlDqaLEqfSaW8Z7N5Jw+lyIW869EzT73/F5lhtY9cLGoVxSXznfgfXMO0Z5K0o0Q2TkTXq+0KFsdnSe3jDViA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve-url-loader/node_modules/postcss": {
|
||||
"version": "7.0.39",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
|
||||
"integrity": "sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^0.2.1",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-url-loader/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -21191,6 +21195,20 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/js-yaml": {
|
||||
"version": "3.15.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
|
||||
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/nth-check": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz",
|
||||
@@ -21315,6 +21333,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
|
||||
@@ -22332,9 +22368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server/node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -22429,9 +22465,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/websocket-driver": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
|
||||
"version": "0.7.5",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
|
||||
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -23010,9 +23046,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.10",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
|
||||
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+17
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.10",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.8.0",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"axios": "^1.16.0",
|
||||
"antd": "^5.2.0",
|
||||
"@ant-design/icons": "^5.0.0",
|
||||
@@ -30,6 +30,21 @@
|
||||
"@testing-library/user-event": "^14.4.3",
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.0"
|
||||
},
|
||||
"overrides": {
|
||||
"ws": "7.5.11",
|
||||
"webpack-dev-server": { "ws": "8.21.0" },
|
||||
"form-data": "4.0.6",
|
||||
"jsdom": { "form-data": "3.0.5" },
|
||||
"js-yaml": "3.15.0",
|
||||
"eslint": { "js-yaml": "4.2.0" },
|
||||
"@eslint/eslintrc": { "js-yaml": "4.2.0" },
|
||||
"http-proxy-middleware": "2.0.10",
|
||||
"launch-editor": "2.14.1",
|
||||
"postcss": "8.5.10",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"@babel/core": "7.29.6",
|
||||
"websocket-driver": "0.7.5"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
|
||||
@@ -53,19 +53,19 @@ const MATCH_TYPE_GROUPS = [
|
||||
{ label: 'Advanced', options: MATCH_TYPES.filter(m => m.category === 'Advanced') },
|
||||
];
|
||||
|
||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — the `-f <file>`
|
||||
// flag was removed from the visual builder because HAProxy OpenManager
|
||||
// does not provision pattern files onto the HAProxy node filesystem.
|
||||
// Allowing `-f` in the visual builder produced ACL rules that passed
|
||||
// every UI / Pydantic / heuristic check but ALWAYS failed HAProxy's
|
||||
// real `-c` parse at apply time with "failed to open pattern file".
|
||||
// Operators reported a multi-page wizard run ending at the Apply
|
||||
// Management red-badge for a footgun the UI made trivial to step on.
|
||||
// The Pydantic validators on the manual API + wizard reject `-f`
|
||||
// universally; the visual builder simply removes the option from the
|
||||
// dropdown so operators cannot author the unsupported state.
|
||||
// Issue #38 follow-up — `-f <file>` is back in the visual builder:
|
||||
// the Bulgu #12 removal (and the matching Pydantic rejects) assumed a
|
||||
// missing pattern file would surprise the operator at apply time, but
|
||||
// the agent runs `haproxy -c` before every reload so a missing file
|
||||
// fails safely (previous config keeps running), and bulk import plus
|
||||
// the free-form fields always accepted `-f`. Pattern files are
|
||||
// operator-managed host files, same policy as SPOE filter configs
|
||||
// (v1.8.8). The value field carries the file path (e.g. flag `-f`
|
||||
// + value `/etc/haproxy/blacklist.lst`); an informational note is
|
||||
// rendered on rules that use it.
|
||||
const FLAGS = [
|
||||
{ value: '-i', label: '-i (case insensitive)' },
|
||||
{ value: '-f', label: '-f (pattern file on host)' },
|
||||
{ value: '-m beg', label: '-m beg (begins with)' },
|
||||
{ value: '-m end', label: '-m end (ends with)' },
|
||||
{ value: '-m sub', label: '-m sub (contains)' },
|
||||
@@ -396,25 +396,23 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
|
||||
};
|
||||
const isRaw = rule.raw !== undefined;
|
||||
|
||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — surface `-f` flag
|
||||
// usage inline. The Pydantic validator rejects the rule server-side,
|
||||
// but operators benefit from seeing the error AS they type / when
|
||||
// they re-open a draft that carries a `-f`-flagged rule (e.g. from
|
||||
// a pre-fix draft). The error message matches the Pydantic error
|
||||
// verbatim so support flows are consistent.
|
||||
// Issue #38 follow-up — `-f <file>` pattern-file references are
|
||||
// ACCEPTED now (the Bulgu #12 reject was removed server-side too).
|
||||
// We still detect them, but only to render an informational note:
|
||||
// the referenced file is operator-managed and must exist on every
|
||||
// HAProxy host; a missing file fails safely at the agent's
|
||||
// pre-reload `haproxy -c`.
|
||||
const rawHasFileFlag = isRaw && typeof rule.raw === 'string' && ACL_FILE_FLAG_PATTERN.test(rule.raw);
|
||||
const structuredHasFileFlag =
|
||||
!isRaw && Array.isArray(rule.flags) && rule.flags.includes('-f');
|
||||
const hasFileFlag = rawHasFileFlag || structuredHasFileFlag;
|
||||
const cardStyleWithError = hasFileFlag
|
||||
? { ...ruleCardStyle, border: `1px solid ${token.colorError}` }
|
||||
: ruleCardStyle;
|
||||
const cardStyleWithError = ruleCardStyle;
|
||||
const FILE_FLAG_TOOLTIP =
|
||||
"ACL pattern-file references (-f <file>) are not supported by "
|
||||
+ "HAProxy OpenManager: the product does not provision pattern "
|
||||
+ "files onto the HAProxy node filesystem, so the reference "
|
||||
+ "would fail at HAProxy reload time. Remove '-f' and use inline "
|
||||
+ "values instead.";
|
||||
"This rule references a pattern file (-f <file>). The file must "
|
||||
+ "exist at that exact path on every HAProxy host in the cluster — "
|
||||
+ "HAProxy OpenManager does not create or distribute pattern files. "
|
||||
+ "A missing file fails safely at 'haproxy -c' (the previous config "
|
||||
+ "keeps running).";
|
||||
|
||||
if (isRaw) {
|
||||
return (
|
||||
@@ -427,11 +425,10 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
|
||||
onChange={(e) => onChange(index, { raw: e.target.value })}
|
||||
placeholder="Raw ACL rule (e.g. my_acl path_beg /api)"
|
||||
prefix={<Tag color="default" style={{ marginRight: 4 }}>RAW</Tag>}
|
||||
status={hasFileFlag ? 'error' : undefined}
|
||||
/>
|
||||
</Tooltip>
|
||||
{hasFileFlag && (
|
||||
<Text type="danger" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
||||
{FILE_FLAG_TOOLTIP}
|
||||
</Text>
|
||||
)}
|
||||
@@ -549,12 +546,11 @@ function ACLDefinitionCard({ rule, index, onChange, onDelete }) {
|
||||
onChange={(e) => onChange(index, { ...rule, value: e.target.value })}
|
||||
placeholder={matchDef?.placeholder || 'Value'}
|
||||
size="small"
|
||||
status={structuredHasFileFlag ? 'error' : undefined}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{structuredHasFileFlag && (
|
||||
<Text type="danger" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 2 }}>
|
||||
{FILE_FLAG_TOOLTIP}
|
||||
</Text>
|
||||
)}
|
||||
@@ -891,11 +887,11 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re
|
||||
.map(d => d.name);
|
||||
}, [aclDefs]);
|
||||
|
||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — count rules that
|
||||
// still carry the unsupported `-f <file>` flag. Surfaced as a
|
||||
// section-level Alert so operators know the section as a whole
|
||||
// has invalid rules even if individual cards / raw text would
|
||||
// otherwise need scrolling to find them.
|
||||
// Issue #38 follow-up — count rules that reference a `-f <file>`
|
||||
// pattern file. Surfaced as a section-level informational Alert
|
||||
// (non-blocking): the file is operator-managed and must exist on
|
||||
// every HAProxy host; a missing file fails safely at the agent's
|
||||
// pre-reload `haproxy -c`.
|
||||
const fileFlagRuleCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const d of aclDefs) {
|
||||
@@ -1153,19 +1149,19 @@ export default function ACLRuleBuilder({ aclRules = [], useBackendRules = [], re
|
||||
Define named conditions to match incoming requests by path, header, source IP, and more.
|
||||
</Text>
|
||||
|
||||
{/* Phase K Phase D follow-up (Bulgu #12 round 3) — section-
|
||||
level warning when one or more rules still carry the
|
||||
unsupported `-f <file>` pattern-file flag. Render as a
|
||||
blocking-style Alert so the operator notices BEFORE
|
||||
Submit. The Pydantic validator rejects the same shape
|
||||
server-side; this is the up-front authoring guardrail. */}
|
||||
{/* Issue #38 follow-up — section-level informational note
|
||||
when one or more rules reference `-f <file>` pattern
|
||||
files. Non-blocking: pattern files are operator-managed
|
||||
host files (the Bulgu #12 reject was removed) and a
|
||||
missing file fails safely at the agent's pre-reload
|
||||
`haproxy -c`. */}
|
||||
{fileFlagRuleCount > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 8 }}
|
||||
message={`${fileFlagRuleCount} ACL rule${fileFlagRuleCount === 1 ? '' : 's'} use the unsupported \`-f <file>\` flag`}
|
||||
description="HAProxy OpenManager does not provision pattern files onto the HAProxy node filesystem, so any `-f /path/...` reference would fail HAProxy reload at apply time with 'failed to open pattern file'. Remove the `-f` flag and switch to inline values (e.g. `src 10.0.0.0/24` instead of `src -f /etc/haproxy/admins.lst`)."
|
||||
message={`${fileFlagRuleCount} ACL rule${fileFlagRuleCount === 1 ? '' : 's'} reference a \`-f <file>\` pattern file`}
|
||||
description="The referenced file must exist at that exact path on every HAProxy host in the cluster — HAProxy OpenManager does not create or distribute pattern files. A missing file fails safely at 'haproxy -c' (the previous config keeps running)."
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -458,6 +458,8 @@ backend web-backend
|
||||
{record.request_headers && <Tag color="blue">Req Headers</Tag>}
|
||||
{record.response_headers && <Tag color="green">Resp Headers</Tag>}
|
||||
{record.tcp_request_rules && <Tag color="purple">TCP Rules</Tag>}
|
||||
{record.filters && <Tag color="magenta">Filters</Tag>}
|
||||
{record.log_format && <Tag color="geekblue">Log Format</Tag>}
|
||||
{record.acl_rules && record.acl_rules.length > 0 && <Tag color="orange">{record.acl_rules.length} ACLs</Tag>}
|
||||
{record.use_backend_rules && record.use_backend_rules.length > 0 && <Tag color="cyan">{record.use_backend_rules.length} Routes</Tag>}
|
||||
</Space>
|
||||
@@ -1076,8 +1078,9 @@ backend web-backend
|
||||
size="small"
|
||||
expandable={{
|
||||
expandedRowRender: (frontend) => {
|
||||
const hasDetails = frontend.request_headers || frontend.response_headers ||
|
||||
frontend.options || frontend.tcp_request_rules ||
|
||||
const hasDetails = frontend.request_headers || frontend.response_headers ||
|
||||
frontend.options || frontend.tcp_request_rules ||
|
||||
frontend.filters || frontend.log_format ||
|
||||
(frontend.acl_rules && frontend.acl_rules.length > 0) ||
|
||||
(frontend.use_backend_rules && frontend.use_backend_rules.length > 0);
|
||||
|
||||
@@ -1157,12 +1160,52 @@ backend web-backend
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<MultiLineDiffRenderer
|
||||
<MultiLineDiffRenderer
|
||||
value={frontend.tcp_request_rules}
|
||||
changeInfo={frontend._changes?.tcp_request_rules}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{/* Issue #38: SPOE filters */}
|
||||
{frontend.filters && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<span>
|
||||
Filters (SPOE/WAF)
|
||||
{frontend._changes?.filters && (
|
||||
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||
{frontend._changes.filters.old ? 'CHANGED' : 'NEW'}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<MultiLineDiffRenderer
|
||||
value={frontend.filters}
|
||||
changeInfo={frontend._changes?.filters}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{/* Issue #38: frontend log-format */}
|
||||
{frontend.log_format && (
|
||||
<Descriptions.Item
|
||||
label={
|
||||
<span>
|
||||
Log Format
|
||||
{frontend._changes?.log_format && (
|
||||
<Tag color="green" style={{ marginLeft: 8, fontSize: '10px' }}>
|
||||
{frontend._changes.log_format.old ? 'CHANGED' : 'NEW'}
|
||||
</Tag>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<MultiLineDiffRenderer
|
||||
value={frontend.log_format}
|
||||
changeInfo={frontend._changes?.log_format}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{frontend.acl_rules && frontend.acl_rules.length > 0 && (
|
||||
<Descriptions.Item label={`ACL Rules (${frontend.acl_rules.length})`}>
|
||||
{frontend.acl_rules.map((acl, idx) => (
|
||||
|
||||
@@ -642,7 +642,11 @@ const FrontendManagement = () => {
|
||||
// Explicitly set options field to handle null/undefined case (NEW field)
|
||||
options: frontend.options || '',
|
||||
// BUGFIX: Explicitly set tcp_request_rules field to handle null/undefined case
|
||||
tcp_request_rules: frontend.tcp_request_rules || ''
|
||||
tcp_request_rules: frontend.tcp_request_rules || '',
|
||||
// Issue #38: SPOE filters + frontend log-format (null → '' so the
|
||||
// TextAreas populate on edit and round-trip on save, preventing null-wipe)
|
||||
log_format: frontend.log_format || '',
|
||||
filters: frontend.filters || ''
|
||||
});
|
||||
|
||||
// Update SSL field visibility after setting values
|
||||
@@ -862,31 +866,13 @@ const FrontendManagement = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase K Phase D follow-up (Bulgu #12 round 3) — hard-gate any
|
||||
// ACL / use_backend / redirect rule that carries the unsupported
|
||||
// HAProxy `-f <file>` pattern-file flag. The Pydantic validator
|
||||
// on the backend (`models/frontend.py::validate_acl_rules`)
|
||||
// rejects the same shape; blocking here surfaces the error
|
||||
// immediately at the manual frontend form and matches the wizard
|
||||
// gate so operators see consistent behaviour between the two
|
||||
// entry points.
|
||||
const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/;
|
||||
const aclRulesAll = [
|
||||
...(aclBuilderData.aclRules || []),
|
||||
...(aclBuilderData.useBackendRules || []),
|
||||
...(aclBuilderData.redirectRules || []).map(
|
||||
(r) => (typeof r === 'string' ? r : ''),
|
||||
),
|
||||
];
|
||||
if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) {
|
||||
message.error(
|
||||
'One or more ACL / routing / redirect rules use the unsupported HAProxy ' +
|
||||
'`-f <file>` pattern-file flag. HAProxy OpenManager does not provision ' +
|
||||
'pattern files onto the HAProxy node filesystem, so the reference would ' +
|
||||
'fail at reload time. Remove the `-f` flag and use inline values instead.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Issue #38 follow-up — the Bulgu #12 client-side hard gate for
|
||||
// the ACL `-f <file>` pattern-file flag was removed together with
|
||||
// the server-side Pydantic rejects: pattern files are operator-
|
||||
// managed host files (same policy as SPOE filter configs since
|
||||
// v1.8.8) and the agent's pre-reload `haproxy -c` makes a missing
|
||||
// file fail safely. The server response now carries a non-blocking
|
||||
// warning listing the referenced files (rendered below).
|
||||
|
||||
// Phase K Phase D follow-up (Bulgu #13) — gate for
|
||||
// self-contradictory routing / redirect conditions (`X !X`).
|
||||
@@ -1178,8 +1164,31 @@ const FrontendManagement = () => {
|
||||
} else {
|
||||
message.success('Frontend created successfully');
|
||||
}
|
||||
|
||||
// Issue #38 follow-up — surface server-emitted warnings on
|
||||
// CREATE too (e.g. the `-f <file>` pattern-file advisory).
|
||||
// Mirrors the update-branch rendering above.
|
||||
const createWarnings = Array.isArray(response.data?.warnings)
|
||||
? response.data.warnings
|
||||
: [];
|
||||
if (createWarnings.length > 0) {
|
||||
message.warning(
|
||||
<div>
|
||||
<div><strong>Frontend saved, but the server flagged {createWarnings.length} rule warning(s):</strong></div>
|
||||
<div style={{ marginTop: 6, fontSize: '12px', fontFamily: 'monospace' }}>
|
||||
{createWarnings.slice(0, 5).map((w, i) => (
|
||||
<div key={i}>• {w.length > 240 ? `${w.slice(0, 237)}...` : w}</div>
|
||||
))}
|
||||
{createWarnings.length > 5 && (
|
||||
<div>(+{createWarnings.length - 5} more)</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setModalVisible(false);
|
||||
fetchFrontends();
|
||||
fetchSSLCertificates(); // Refresh SSL certificates after frontend update
|
||||
@@ -2250,6 +2259,40 @@ tcp-request connection reject if { src -f /etc/haproxy/blacklist.lst }`}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Issue #38: SPOE filters + frontend log-format */}
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="filters"
|
||||
label="Filters (SPOE / WAF)"
|
||||
extra="HAProxy filter directives (one per line). Emitted before send-spoe-group rules."
|
||||
tooltip="e.g. Coraza WAF via SPOE. The referenced engine config file and its SPOA backend must exist on the HAProxy host."
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder={`Examples:
|
||||
filter spoe engine coraza config /etc/haproxy/coraza.cfg`}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="log_format"
|
||||
label="Custom Log Format"
|
||||
extra="HAProxy log-format / log-format-sd directive (kept verbatim)"
|
||||
tooltip="Overrides option httplog/tcplog. Use the full directive including the quoted format string."
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder={'log-format "%ci:%cp [%t] %ft %b/%s %ST %B %{+Q}r"'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
|
||||
|
||||
@@ -3177,36 +3177,15 @@ const SiteWizard = () => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Phase K Phase D follow-up (Bulgu #12 round 3) —
|
||||
// hard-gate the Step 2 → Step 3 advance on any ACL
|
||||
// rule that carries the unsupported `-f <file>`
|
||||
// pattern-file flag. The Pydantic validator rejects
|
||||
// the same shape at submit, but blocking the Next
|
||||
// button here surfaces the error immediately at
|
||||
// its source step (the ACL builder is right above)
|
||||
// instead of bouncing the operator from Step 4's
|
||||
// dry-run card back to Step 2 with a less-specific
|
||||
// jumpback button. The ACLRuleBuilder ALSO renders
|
||||
// a section-level red Alert when this state is
|
||||
// active so the operator already sees what to fix.
|
||||
const FILE_FLAG_RE = /(?:^|\s)-f(?:\s|$)/;
|
||||
const aclRulesAll = [
|
||||
...(aclBuilderData.aclRules || []),
|
||||
...(aclBuilderData.useBackendRules || []),
|
||||
...(aclBuilderData.redirectRules || []).map(
|
||||
(r) => (typeof r === 'string' ? r : ''),
|
||||
),
|
||||
];
|
||||
if (aclRulesAll.some((r) => typeof r === 'string' && FILE_FLAG_RE.test(r))) {
|
||||
message.error(
|
||||
'One or more rules use the unsupported HAProxy `-f <file>` ' +
|
||||
'pattern-file flag. HAProxy OpenManager does not provision ' +
|
||||
'pattern files onto the HAProxy node filesystem, so the ' +
|
||||
'reference would fail at reload time. Remove the `-f` flag ' +
|
||||
'and use inline values instead before continuing.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Issue #38 follow-up — the Bulgu #12 Step 2 → 3
|
||||
// hard gate for the ACL `-f <file>` pattern-file
|
||||
// flag was removed together with the server-side
|
||||
// Pydantic rejects: pattern files are operator-
|
||||
// managed host files (same policy as SPOE filter
|
||||
// configs since v1.8.8) and the agent's pre-reload
|
||||
// `haproxy -c` makes a missing file fail safely.
|
||||
// The ACLRuleBuilder renders an informational note
|
||||
// on `-f` rules instead of a blocking error.
|
||||
// Phase K Phase D follow-up (Bulgu #13) — block
|
||||
// advance when any routing / redirect rule has a
|
||||
// self-contradictory condition (`acl1 !acl1`).
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
serviceAccountName: haproxy-openmanager-redis
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
image: redis:8.8.0-alpine
|
||||
command:
|
||||
- redis-server
|
||||
- /usr/local/etc/redis/redis.conf
|
||||
|
||||
Reference in New Issue
Block a user