Skip to content

Commit f3d4fb1

Browse files
fix(agent): accept agent token (X-API-Key) on cluster read endpoints (Issue #22)
Assigning a HAProxy agent failed with '401: Authorization header missing' on GET /api/clusters. A cluster-read hardening had made GET /api/clusters and GET /api/clusters/{id} accept only a user JWT in the Authorization header; agents authenticate with their agent token in the X-API-Key header, so the token was never read. Both endpoints now accept either a user JWT (Authorization) or an agent token (X-API-Key via validate_agent_api_key), mirroring the existing dual-auth on POST /api/agents/generate-install-script. Anonymous access is still rejected, so the original hardening is preserved. The auth guard is placed before the try block so the failure surfaces as a clean 401 (not the 500-wrapped-401 in the report). Agent install scripts now consistently send the token via X-API-Key (pre-flight cluster check on linux/macos, and macOS get_cluster_paths which previously used the wrong Authorization: Bearer header). Also normalizes the platform in the uninstall-script generator so macOS agents (which report platform 'darwin') no longer get a 400 from GET /api/agents/generate-uninstall-script/darwin. version 1.6.0 -> 1.6.2.
1 parent 0692f26 commit f3d4fb1

8 files changed

Lines changed: 67 additions & 57 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2367,6 +2367,9 @@ Developed with ❤️ for the HAProxy community
23672367

23682368
## Release Notes
23692369

2370+
- **v1.6.2** (2026-05-30) — Bugfixes: (1) agents (which authenticate with their `X-API-Key` token) could not reach `GET /api/clusters` / `/api/clusters/{id}` after the v1.5.x cluster-read hardening, breaking agent assignment ("401: Authorization header missing"); these endpoints now accept either a user JWT or an agent token (anonymous access is still rejected). (2) The uninstall-script generator returned 400 for macOS agents (which report platform `darwin`); it now normalizes the platform the same way the install generator does. No UI or schema changes.
2371+
- **v1.6.1** (2026-05-21) — Security patch: bump `axios` to 1.16.x (prototype-pollution hardening, header-injection fix, keep-alive memory leak fix) and `fast-uri` to 3.1.2 (GHSA-v39h-62p7-jpjc). No functional changes.
2372+
23702373
For full release notes and the list of features delivered in each version (v1.5.x Site Wizard + ACME Diagnostic Panel, v1.4.0 ACME stability + enterprise audit, v1.3.0, ...) see the [GitHub Releases](https://github.com/taylanbakircioglu/haproxy-openmanager/releases) page.
23712374

23722375
---

backend/routers/agent.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -730,14 +730,15 @@ async def generate_uninstall_script(platform: str, authorization: str = Header(N
730730
```
731731
"""
732732
try:
733-
# Validate platform
734-
platform_lower = platform.lower()
735-
if platform_lower not in ['linux', 'macos']:
736-
raise HTTPException(
737-
status_code=400,
738-
detail=f"Invalid platform: {platform}. Must be 'linux' or 'macos'"
739-
)
740-
733+
# Normalize platform to a canonical key (always 'linux' or 'macos').
734+
# macOS agents register with platform 'darwin' (from `uname -s`), so the
735+
# strict ['linux','macos'] check used to 400 on the UI's uninstall flow
736+
# (GET /generate-uninstall-script/darwin). Reuse the same get_platform_key()
737+
# helper the install-script generator uses, so darwin/osx/mac and the
738+
# linux distro variants all resolve correctly. Backward-compatible:
739+
# 'linux'/'macos' still map to themselves.
740+
platform_lower = get_platform_key(platform)
741+
741742
# Read uninstall script from agent_scripts directory (same as install scripts)
742743
import os
743744
script_filename = f"uninstall-agent-{platform_lower}.sh"

backend/routers/cluster.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authori
517517

518518

519519
@router.get("/{cluster_id}", summary="Get Cluster by ID", response_description="Cluster details")
520-
async def get_cluster(cluster_id: int, authorization: str = Header(None)):
520+
async def get_cluster(cluster_id: int, authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
521521
"""
522522
# Get Specific HAProxy Cluster
523523
@@ -528,8 +528,12 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
528528
529529
## Example Request
530530
```bash
531+
# User (UI) authentication:
531532
curl -X GET "{BASE_URL}/api/clusters/1" \\
532533
-H "Authorization: Bearer eyJhbGciOiJIUz..."
534+
# Agent authentication (agent token in X-API-Key):
535+
curl -X GET "{BASE_URL}/api/clusters/1" \\
536+
-H "X-API-Key: hap_..."
533537
```
534538
535539
## Example Response
@@ -554,15 +558,24 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
554558
- **404**: Cluster not found
555559
- **500**: Server error
556560
"""
557-
try:
558-
# R18c audit fix (round 6 final convergence): authenticate
559-
# the caller before fetching cluster topology by ID. Pre-fix
560-
# this sibling of GET /api/clusters was anonymous, so an
561-
# attacker could iterate cluster IDs to enumerate the same
562-
# info (stats socket, paths, ACME flags, pool identity) the
563-
# list endpoint just locked down. Closes the asymmetry.
561+
# R18c audit fix (round 6 final convergence): authenticate the caller
562+
# before fetching cluster topology by ID. Pre-fix this sibling of
563+
# GET /api/clusters was anonymous, so an attacker could iterate cluster
564+
# IDs to enumerate the same info (stats socket, paths, ACME flags, pool
565+
# identity) the list endpoint just locked down.
566+
# Issue #22: agents send their token in the X-API-Key header (not a user
567+
# JWT), so accept either credential — mirrors the dual-auth on
568+
# POST /api/agents/generate-install-script. Anonymous is still rejected.
569+
if authorization:
564570
from auth_middleware import get_current_user_from_token
565571
await get_current_user_from_token(authorization)
572+
elif x_api_key:
573+
from auth_middleware import validate_agent_api_key
574+
if not await validate_agent_api_key(x_api_key):
575+
raise HTTPException(status_code=401, detail="Invalid agent API key")
576+
else:
577+
raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required")
578+
try:
566579
conn = await get_database_connection()
567580

568581
cluster = await conn.fetchrow("""
@@ -602,16 +615,20 @@ async def get_cluster(cluster_id: int, authorization: str = Header(None)):
602615
raise HTTPException(status_code=500, detail=str(e))
603616

604617
@router.get("", summary="Get All Clusters", response_description="List of all clusters")
605-
async def get_clusters(authorization: str = Header(None)):
618+
async def get_clusters(authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
606619
"""
607620
# Get All HAProxy Clusters
608621
609622
Retrieve a list of all active HAProxy clusters with their agent counts and pool information.
610623
611624
## Example Request
612625
```bash
626+
# User (UI) authentication:
613627
curl -X GET "{BASE_URL}/api/clusters" \\
614628
-H "Authorization: Bearer eyJhbGciOiJIUz..."
629+
# Agent authentication (agent token in X-API-Key):
630+
curl -X GET "{BASE_URL}/api/clusters" \\
631+
-H "X-API-Key: hap_..."
615632
```
616633
617634
## Example Response
@@ -662,18 +679,28 @@ async def get_clusters(authorization: str = Header(None)):
662679
## Error Responses
663680
- **500**: Server error
664681
"""
665-
try:
666-
# R18c audit fix (round 6 #3 — KRITIK info leak): require an
667-
# authenticated caller. Pre-fix the endpoint accepted
668-
# anonymous GETs and returned cluster topology including
669-
# internal HAProxy paths (stats socket, config path, bin
670-
# path), pool ids, ACME flags, and agent counts. This is
671-
# both reconnaissance for an attacker and the spine of the
672-
# cluster-scoped RBAC the rest of the platform builds on,
673-
# so guarding it at the read layer is essential after R18c
674-
# round 5's roster + role guards.
682+
# R18c audit fix (round 6 #3 — KRITIK info leak): require an authenticated
683+
# caller. Pre-fix the endpoint accepted anonymous GETs and returned cluster
684+
# topology including internal HAProxy paths (stats socket, config path, bin
685+
# path), pool ids, ACME flags, and agent counts. This is both reconnaissance
686+
# for an attacker and the spine of the cluster-scoped RBAC the rest of the
687+
# platform builds on, so guarding it at the read layer is essential.
688+
# Issue #22: agents send their token in the X-API-Key header (not a user
689+
# JWT), so accept either credential — mirrors the dual-auth on
690+
# POST /api/agents/generate-install-script. Anonymous is still rejected.
691+
# (Guard kept OUTSIDE the try below: get_clusters' broad `except Exception`
692+
# re-wraps raised HTTPExceptions into 500, which produced the "500 - 401"
693+
# in the issue log; raising here yields a clean 401.)
694+
if authorization:
675695
from auth_middleware import get_current_user_from_token
676696
await get_current_user_from_token(authorization)
697+
elif x_api_key:
698+
from auth_middleware import validate_agent_api_key
699+
if not await validate_agent_api_key(x_api_key):
700+
raise HTTPException(status_code=401, detail="Invalid agent API key")
701+
else:
702+
raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required")
703+
try:
677704
conn = await get_database_connection()
678705

679706
clusters = await conn.fetch("""

backend/utils/agent_scripts/linux_install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -635,7 +635,7 @@ if [[ "$SKIP_TO_DAEMON" != "true" ]]; then
635635

636636
# Validate cluster exists by checking management API
637637
log "DEBUG" "Validating HAProxy Cluster..."
638-
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
638+
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "X-API-Key: $AGENT_TOKEN" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
639639
if [[ "$CLUSTER_CHECK" == "FAILED" ]]; then
640640
log "ERROR" "Failed to connect to management API!"
641641
echo " Please check your network connection and management URL."

backend/utils/agent_scripts/macos_install.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ if [[ "$SKIP_TO_DAEMON" != "true" ]]; then
522522

523523
# Validate cluster exists by checking management API
524524
log "DEBUG" "Validating HAProxy Cluster..."
525-
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
525+
CLUSTER_CHECK=$("$CURL_BIN" -k -s -f "$MANAGEMENT_URL/api/clusters" -H "X-API-Key: $AGENT_TOKEN" -H "User-Agent: haproxy-agent-installer" || echo "FAILED")
526526
if [[ "$CLUSTER_CHECK" == "FAILED" ]]; then
527527
log "ERROR" "Failed to connect to management API!"
528528
echo " Please check your network connection and management URL."
@@ -867,7 +867,7 @@ SSL_SYNC_TIMESTAMP_FILE="/tmp/haproxy-agent-ssl-sync-${AGENT_NAME}"
867867
get_cluster_paths() {
868868
log "DEBUG" "Fetching cluster paths from management API"
869869
local cluster_response=$("$CURL_BIN" -k -s -X GET "$MANAGEMENT_URL/api/clusters" \
870-
-H "Authorization: Bearer $AGENT_TOKEN")
870+
-H "X-API-Key: $AGENT_TOKEN")
871871
872872
if [[ $? -eq 0 ]]; then
873873
local _cfg _bin _sock

frontend/package-lock.json

Lines changed: 2 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "haproxy-openmanager-frontend",
3-
"version": "1.6.0",
3+
"version": "1.6.2",
44
"description": "HAProxy Load Balancer Management UI",
55
"license": "AGPL-3.0-or-later",
66
"dependencies": {

version.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "1.6.0",
3-
"releaseName": "Multi-Factor Authentication (MFA)",
4-
"releaseDate": "2026-05-18"
2+
"version": "1.6.2",
3+
"releaseName": "Agent auth fix (issue #22)",
4+
"releaseDate": "2026-05-30"
55
}

0 commit comments

Comments
 (0)