diff --git a/.gitignore b/.gitignore
index 8145aa89..ef2cd714 100644
--- a/.gitignore
+++ b/.gitignore
@@ -189,6 +189,8 @@ node_modules/
# package-lock.json is intentionally TRACKED — `npm ci` and the CI setup-node
# cache both require it. Ignoring it silently drops the lockfile from any export
# built off the working tree, which breaks every frontend CI job.
+package-lock.json
+!frontend-v2/package-lock.json
*.pyc
# Environment
# OpenCode
diff --git a/backend/alembic/versions/v2_156_add_waf_ingestion_cursors.py b/backend/alembic/versions/v2_156_add_waf_ingestion_cursors.py
new file mode 100644
index 00000000..8dbd09ec
--- /dev/null
+++ b/backend/alembic/versions/v2_156_add_waf_ingestion_cursors.py
@@ -0,0 +1,33 @@
+"""Add waf_ingestion_cursors table for ClickHouse ingest tracking.
+
+Revision ID: v2_142
+Revises: v2_141
+Create Date: 2026-08-22
+"""
+
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "v2_156"
+down_revision = "v2_155"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "waf_ingestion_cursors",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("cluster_id", sa.Integer(), nullable=False),
+ sa.Column("log_file", sa.String(512), nullable=False),
+ sa.Column("last_line_num", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
+ sa.UniqueConstraint("cluster_id", "log_file", name="uq_waf_cursor_cluster_file"),
+ )
+ op.create_index("ix_waf_ingestion_cursors_cluster_id", "waf_ingestion_cursors", ["cluster_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_waf_ingestion_cursors_cluster_id", table_name="waf_ingestion_cursors")
+ op.drop_table("waf_ingestion_cursors")
diff --git a/backend/alembic/versions/v2_157_add_waf_panels.py b/backend/alembic/versions/v2_157_add_waf_panels.py
new file mode 100644
index 00000000..6ebaef23
--- /dev/null
+++ b/backend/alembic/versions/v2_157_add_waf_panels.py
@@ -0,0 +1,44 @@
+"""Add waf_panels table for panel builder.
+
+Replaces waf_ingestion_cursors (Celery ingest removed; OTEL handles ingest now).
+
+Revision ID: v2_143
+Revises: v2_142
+"""
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "v2_157"
+down_revision = "v2_156"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "waf_panels",
+ # checkfirst=True handled at migration level — table may exist from manual creation
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ # no FK to keep schema portable, matches waf_ingestion_cursors / waf_dashboard_tabs
+ # (the prior sa.ForeignKey("k8s_clusters.id") referenced a table that does not exist —
+ # the clusters table is "kubernetes_clusters" — so it failed to create on Postgres)
+ sa.Column("cluster_id", sa.Integer(), nullable=False, index=True),
+ sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("title", sa.String(255), nullable=False),
+ sa.Column("chart_type", sa.String(50), nullable=False, server_default="bar"),
+ sa.Column("query_template", sa.String(100), nullable=False),
+ sa.Column("groupby_field", sa.String(100), nullable=True),
+ sa.Column("time_range", sa.String(10), nullable=False, server_default="7d"),
+ sa.Column("panel_order", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("width", sa.String(10), nullable=False, server_default="full"),
+ sa.Column("extra_config", sa.JSON(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), onupdate=sa.text("now()")),
+ )
+ op.create_index("idx_waf_panels_cluster", "waf_panels", ["cluster_id", "panel_order"])
+
+
+def downgrade() -> None:
+ op.drop_index("idx_waf_panels_cluster", "waf_panels")
+ op.drop_table("waf_panels")
diff --git a/backend/alembic/versions/v2_158_add_waf_dashboard_tabs.py b/backend/alembic/versions/v2_158_add_waf_dashboard_tabs.py
new file mode 100644
index 00000000..27f39a6c
--- /dev/null
+++ b/backend/alembic/versions/v2_158_add_waf_dashboard_tabs.py
@@ -0,0 +1,33 @@
+"""Add waf_dashboard_tabs table + tab_id on waf_panels for custom dashboard tabs.
+
+Revision ID: v2_144
+Revises: v2_143
+"""
+import sqlalchemy as sa
+
+from alembic import op
+
+revision = "v2_158"
+down_revision = "v2_157"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "waf_dashboard_tabs",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("cluster_id", sa.Integer(), nullable=False, index=True), # no FK to keep schema portable, matches waf_panels
+ sa.Column("name", sa.String(100), nullable=False),
+ sa.Column("tab_order", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), onupdate=sa.text("now()")),
+ )
+ op.create_index("idx_waf_dashboard_tabs_cluster", "waf_dashboard_tabs", ["cluster_id", "tab_order"])
+ op.add_column("waf_panels", sa.Column("tab_id", sa.Integer(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("waf_panels", "tab_id")
+ op.drop_index("idx_waf_dashboard_tabs_cluster", "waf_dashboard_tabs")
+ op.drop_table("waf_dashboard_tabs")
diff --git a/backend/celery_app.py b/backend/celery_app.py
index 2d42a89f..999123e8 100644
--- a/backend/celery_app.py
+++ b/backend/celery_app.py
@@ -153,6 +153,7 @@
'task': 'tasks.notification_retention_task.purge_old_notifications',
'schedule': 24 * 60 * 60, # Every 24 hours
},
+ # waf-event-ingest removed — OTEL Collector now handles WAF syslog ingest directly into ClickHouse
},
)
diff --git a/backend/core/k8s_resource_registry.py b/backend/core/k8s_resource_registry.py
index 66d2295b..ec4a8bef 100644
--- a/backend/core/k8s_resource_registry.py
+++ b/backend/core/k8s_resource_registry.py
@@ -681,6 +681,52 @@ def _certmanager_resource(kind: str, plural: str, display_name: str,
description="F5 CIS Policy — reusable traffic policy for VirtualServer/TransportServer (EOL Apr 2026)",
category=ResourceCategory.F5_CIS,
),
+
+ # =========================================================================
+ # WAF POLICY MANAGER — nap-policy-operator PLM CRDs (appprotect.f5.com)
+ # Unmodified upstream CRD schema; PLM's own Policy Controller compiles
+ # spec.policy into a bundle and writes status.bundle.
+ # =========================================================================
+ "appolicy": K8sResourceType(
+ api_group=ApiGroups.APPPROTECT,
+ api_version="v1",
+ kind="APPolicy",
+ plural="appolicies",
+ namespaced=True,
+ display_name="WAF Policy",
+ description="App Protect WAF policy, compiled by the PLM Policy Controller",
+ category=ResourceCategory.WAF
+ ),
+ "aplogconf": K8sResourceType(
+ api_group=ApiGroups.APPPROTECT,
+ api_version="v1",
+ kind="APLogConf",
+ plural="aplogconfs",
+ namespaced=True,
+ display_name="WAF Log Profile",
+ description="App Protect WAF security logging profile",
+ category=ResourceCategory.WAF
+ ),
+ "apsignatures": K8sResourceType(
+ api_group=ApiGroups.APPPROTECT,
+ api_version="v1",
+ kind="APSignatures",
+ plural="apsignatures",
+ namespaced=True,
+ display_name="WAF Signatures",
+ description="Attack/bot signature and threat campaign revisions (singleton per namespace, name must be 'apsignatures')",
+ category=ResourceCategory.WAF
+ ),
+ "apusersig": K8sResourceType(
+ api_group=ApiGroups.APPPROTECT,
+ api_version="v1",
+ kind="APUserSig",
+ plural="apusersigs",
+ namespaced=True,
+ display_name="WAF User Signature",
+ description="User-defined App Protect attack signature",
+ category=ResourceCategory.WAF
+ ),
}
diff --git a/backend/core/k8s_types.py b/backend/core/k8s_types.py
index a880a4e4..e3364d8b 100644
--- a/backend/core/k8s_types.py
+++ b/backend/core/k8s_types.py
@@ -36,6 +36,8 @@ class ApiGroups:
MULTUS = "k8s.cni.cncf.io"
# F5 CIS (Container Ingress Services) — classic BIG-IP integration (D-023)
F5_CIS = "cis.f5.com"
+ # WAF PLM CRDs: APPolicy, APLogConf, APSignatures, APUserSig
+ APPPROTECT = "appprotect.f5.com"
class ResourceCategory:
@@ -54,6 +56,7 @@ class ResourceCategory:
DPF = "dpf"
CLUSTER = "cluster"
F5_CIS = "f5-cis" # F5 Container Ingress Services (D-023)
+ WAF = "waf"
@dataclass
diff --git a/backend/main.py b/backend/main.py
index c0fad42c..b5bfa216 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -83,6 +83,12 @@
tmm_debug_router,
topology_router,
tunnels_router,
+ waf_dashboard_router,
+ waf_dashboard_tabs_router,
+ waf_gateway_router,
+ waf_logs_router,
+ waf_panels_router,
+ waf_policies_router,
)
from routes.k8s_websocket import router as k8s_websocket_router
from routes.licensing import router as licensing_router
@@ -362,6 +368,12 @@ async def dispatch(self, request: Request, call_next):
app.include_router(f5bnk_router) # F5 BNK gateways, topology, health
app.include_router(llm_observability_router) # AI-gateway observability — Loki request analytics
app.include_router(dpf_router) # NVIDIA DPF — DPU devices, clusters, services, health
+app.include_router(waf_policies_router) # WAF Policy Manager — appprotect.f5.com CRDs via PLM
+app.include_router(waf_logs_router) # WAF Security Logs — syslog endpoint resolution + log fetch
+app.include_router(waf_dashboard_router) # WAF Dashboard — ClickHouse analytics
+app.include_router(waf_panels_router) # WAF Panel Builder — CRUD + data queries
+app.include_router(waf_dashboard_tabs_router) # WAF Dashboard — custom tab CRUD
+app.include_router(waf_gateway_router) # WAF Gateway API — Gateway/HTTPRoute/WSP CRUD
app.include_router(bare_metal_hosts_router) # Bare-metal DPU hosts — CRUD + discovery
app.include_router(f5_devices_router) # F5 BIG-IP devices — CRUD + read-only probe (D-023 P1)
app.include_router(f5_credentials_router) # F5 BIG-IP credentials — CRUD + test (D-023 P1)
diff --git a/backend/models/__init__.py b/backend/models/__init__.py
index 0454ba8b..132fa3c1 100644
--- a/backend/models/__init__.py
+++ b/backend/models/__init__.py
@@ -227,6 +227,13 @@
VariableMappingTemplate,
)
+# --- WAF dashboard (panels, tabs, ClickHouse ingest cursors) ---
+from models.waf_panels import (
+ WafDashboardTab,
+ WafIngestionCursor,
+ WafPanel,
+)
+
__all__ = [
"Base",
# enums
@@ -308,4 +315,6 @@
"PolicyEvaluation",
# use-case artifacts (D-034 Phase 0 tracer)
"UseCaseArtifact", "UseCaseArtifactVersion", "UseCaseApplication",
+ # WAF dashboard (panels, tabs, ClickHouse ingest cursors)
+ "WafPanel", "WafDashboardTab", "WafIngestionCursor",
]
diff --git a/backend/models/waf_panels.py b/backend/models/waf_panels.py
new file mode 100644
index 00000000..c970f0a0
--- /dev/null
+++ b/backend/models/waf_panels.py
@@ -0,0 +1,103 @@
+import os
+
+from sqlalchemy import (
+ JSON,
+ Column,
+ DateTime,
+ ForeignKey,
+ Index,
+ Integer,
+ String,
+ UniqueConstraint,
+)
+from sqlalchemy.sql import func
+
+from database import Base
+
+_DB = os.getenv("CLICKHOUSE_DB", "bnkforge")
+
+# Predefined query templates — maps a short key to a ClickHouse SQL fragment.
+# All templates are parameterised by cluster_id and a time range; no free-form SQL.
+# The {db} placeholder is substituted at query-execution time with the configured DB name.
+def _t(sql: str) -> str:
+ return sql.replace("{db}", _DB)
+
+PANEL_QUERY_TEMPLATES = {
+ "events_by_outcome": _t("SELECT outcome AS label, count() AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY label ORDER BY value DESC"),
+ "events_by_attack_type": _t("SELECT attack_type AS label, count() AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY label ORDER BY value DESC LIMIT 10"),
+ "events_by_ip": _t("SELECT ip_client AS label, countIf(outcome='REJECTED') AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY label ORDER BY value DESC LIMIT 10"),
+ "events_by_uri": _t("SELECT uri AS label, count() AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR AND outcome='REJECTED' GROUP BY label ORDER BY value DESC LIMIT 10"),
+ "events_by_policy": _t("SELECT policy_name AS label, count() AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY label ORDER BY value DESC"),
+ "events_by_vs": _t("SELECT vs_name AS label, count() AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY label ORDER BY value DESC"),
+ "blocked_rate_over_time": _t("SELECT toStartOfInterval(ts, INTERVAL {bucket} HOUR) AS ts_bucket, round(countIf(outcome='REJECTED') / count() * 100, 1) AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY ts_bucket ORDER BY ts_bucket"),
+ "trend_by_outcome": _t("SELECT toStartOfInterval(ts, INTERVAL {bucket} HOUR) AS ts_bucket, countIf(outcome='REJECTED') AS REJECTED, countIf(outcome='PASSED') AS PASSED, countIf(outcome='ALERTED') AS ALERTED FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY ts_bucket ORDER BY ts_bucket"),
+ "events_by_ingest_source": _t("SELECT ingest_source AS label, count() AS value FROM {db}.waf_events WHERE cluster_id={cid} AND ts >= now() - INTERVAL {h} HOUR GROUP BY label ORDER BY value DESC"),
+}
+
+VALID_CHART_TYPES = {"bar", "horizontal_bar", "area", "line", "pie", "kpi", "table"}
+VALID_TIME_RANGES = {"1h", "24h", "7d", "30d"}
+VALID_WIDTHS = {"full", "half"}
+
+
+class WafPanel(Base):
+ __tablename__ = "waf_panels"
+ # Must match migration v2_157_add_waf_panels (+ tab_id from v2_158) EXACTLY so
+ # create_all and the migration chain build the same schema (schema-parity gate).
+ __table_args__ = (
+ Index("idx_waf_panels_cluster", "cluster_id", "panel_order"),
+ {"extend_existing": True},
+ )
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ cluster_id = Column(Integer, nullable=False, index=True) # no FK to keep schema portable
+ created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+ tab_id = Column(Integer, nullable=True) # null = legacy/default "Custom" tab
+ title = Column(String(255), nullable=False)
+ chart_type = Column(String(50), nullable=False, default="bar")
+ query_template = Column(String(100), nullable=False)
+ groupby_field = Column(String(100), nullable=True)
+ time_range = Column(String(10), nullable=False, default="7d")
+ panel_order = Column(Integer, nullable=False, default=0)
+ width = Column(String(10), nullable=False, default="full")
+ extra_config = Column(JSON, nullable=True)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+
+class WafDashboardTab(Base):
+ """A user-defined dashboard tab that groups a set of custom panels."""
+ __tablename__ = "waf_dashboard_tabs"
+ # Must match migration v2_158_add_waf_dashboard_tabs EXACTLY (schema-parity gate).
+ __table_args__ = (
+ Index("idx_waf_dashboard_tabs_cluster", "cluster_id", "tab_order"),
+ {"extend_existing": True},
+ )
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ cluster_id = Column(Integer, nullable=False, index=True)
+ name = Column(String(100), nullable=False)
+ tab_order = Column(Integer, nullable=False, default=0)
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+
+
+class WafIngestionCursor(Base):
+ """Tracks the last-ingested line per (cluster, log file) for ClickHouse ingest.
+
+ Mirrors migration v2_156_add_waf_ingestion_cursors EXACTLY so create_all and
+ the migration chain build the same schema (schema-parity gate). The table is
+ not yet used by app code, but the parity gate requires every chain table to
+ have an ORM model.
+ """
+ __tablename__ = "waf_ingestion_cursors"
+ __table_args__ = (
+ UniqueConstraint("cluster_id", "log_file", name="uq_waf_cursor_cluster_file"),
+ Index("ix_waf_ingestion_cursors_cluster_id", "cluster_id"),
+ {"extend_existing": True},
+ )
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ cluster_id = Column(Integer, nullable=False)
+ log_file = Column(String(512), nullable=False)
+ last_line_num = Column(Integer, nullable=False, server_default="0")
+ updated_at = Column(DateTime(timezone=True), server_default=func.now())
diff --git a/backend/openapi.json b/backend/openapi.json
index 8ebd6722..aeae32f3 100644
--- a/backend/openapi.json
+++ b/backend/openapi.json
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "BNK-Forge API",
- "version": "3.1.6"
+ "version": "4.0.0"
},
"paths": {
"/": {
@@ -5265,6 +5265,4654 @@
}
}
},
+ "/api/k8s/clusters/{cluster_id}/waf/policies": {
+ "get": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "List Waf Policies",
+ "operationId": "list_waf_policies_api_k8s_clusters__cluster_id__waf_policies_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafPolicyListResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Create Waf Policy",
+ "operationId": "create_waf_policy_api_k8s_clusters__cluster_id__waf_policies_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafPolicyCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/policies/{name}": {
+ "get": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Get Waf Policy",
+ "operationId": "get_waf_policy_api_k8s_clusters__cluster_id__waf_policies__name__get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Update Waf Policy",
+ "operationId": "update_waf_policy_api_k8s_clusters__cluster_id__waf_policies__name__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafPolicyUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Delete Waf Policy",
+ "operationId": "delete_waf_policy_api_k8s_clusters__cluster_id__waf_policies__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafOperationResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/logconfs": {
+ "get": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "List Waf Logconfs",
+ "operationId": "list_waf_logconfs_api_k8s_clusters__cluster_id__waf_logconfs_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafLogConfListResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Create Waf Logconf",
+ "operationId": "create_waf_logconf_api_k8s_clusters__cluster_id__waf_logconfs_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafLogConfCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/logconfs/{name}": {
+ "put": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Update Waf Logconf",
+ "operationId": "update_waf_logconf_api_k8s_clusters__cluster_id__waf_logconfs__name__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafPolicyUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Delete Waf Logconf",
+ "operationId": "delete_waf_logconf_api_k8s_clusters__cluster_id__waf_logconfs__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafOperationResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/signatures": {
+ "get": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Get Waf Signatures",
+ "operationId": "get_waf_signatures_api_k8s_clusters__cluster_id__waf_signatures_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/WafResource"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Response Get Waf Signatures Api K8S Clusters Cluster Id Waf Signatures Get"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Upsert Waf Signatures",
+ "operationId": "upsert_waf_signatures_api_k8s_clusters__cluster_id__waf_signatures_put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafSignaturesUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Delete Waf Signatures",
+ "operationId": "delete_waf_signatures_api_k8s_clusters__cluster_id__waf_signatures_delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafOperationResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/policies/{name}/recompile": {
+ "post": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Recompile Waf Policy",
+ "description": "Force recompile by bumping a metadata annotation (triggers reconcile loop).",
+ "operationId": "recompile_waf_policy_api_k8s_clusters__cluster_id__waf_policies__name__recompile_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafRecompileResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/usersigs": {
+ "get": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "List Waf Usersigs",
+ "operationId": "list_waf_usersigs_api_k8s_clusters__cluster_id__waf_usersigs_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafUserSigListResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Create Waf Usersig",
+ "operationId": "create_waf_usersig_api_k8s_clusters__cluster_id__waf_usersigs_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafUserSigCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/usersigs/{name}": {
+ "put": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Update Waf Usersig",
+ "operationId": "update_waf_usersig_api_k8s_clusters__cluster_id__waf_usersigs__name__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafPolicyUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafResource"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-policies"
+ ],
+ "summary": "Delete Waf Usersig",
+ "operationId": "delete_waf_usersig_api_k8s_clusters__cluster_id__waf_usersigs__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafOperationResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/security-logs": {
+ "get": {
+ "summary": "Get Waf Security Logs",
+ "description": "Resolve the syslog endpoint for the given CR and return recent security log entries.\n\nResolution chain:\n APPolicy \u2192 SecPolicy (items[].kind=F5BigWebSecurityProfile) \u2192 F5BigLogProfile \u2192 F5BigHslPub\n F5VirtualServer \u2192 SecPolicy (targetRef) \u2192 F5BigLogProfile \u2192 F5BigHslPub",
+ "operationId": "get_waf_security_logs_api_k8s_clusters__cluster_id__waf_security_logs_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ },
+ {
+ "name": "cr_kind",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "'appolicy' or 'f5virtualserver'; omit to query all policies",
+ "title": "Cr Kind"
+ },
+ "description": "'appolicy' or 'f5virtualserver'; omit to query all policies"
+ },
+ {
+ "name": "cr_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Cr Name"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 500,
+ "minimum": 1,
+ "default": 200,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome_filter",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Outcome Filter"
+ }
+ },
+ {
+ "name": "attack_type_filter",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Attack Type Filter"
+ }
+ },
+ {
+ "name": "vs_name_filter",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Vs Name Filter"
+ }
+ },
+ {
+ "name": "ip_filter",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Ip Filter"
+ }
+ },
+ {
+ "name": "uri_filter",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Uri Filter"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafSecurityLogsResponse"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/status": {
+ "get": {
+ "summary": "Dashboard Status",
+ "description": "Return whether ClickHouse is reachable and has data for this cluster.",
+ "operationId": "dashboard_status_api_k8s_clusters__cluster_id__waf_dashboard_status_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/summary": {
+ "get": {
+ "summary": "Dashboard Summary",
+ "description": "Return KPI numbers: total, rejected_pct, top_attack_type, unique_ips.",
+ "operationId": "dashboard_summary_api_k8s_clusters__cluster_id__waf_dashboard_summary_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/trend": {
+ "get": {
+ "summary": "Dashboard Trend",
+ "description": "Return time-bucketed event counts split by outcome.\nBucket size: 1h for \u22647d ranges, 6h for 30d.\nShape: [{ ts, REJECTED, PASSED, ALERTED }, ...]",
+ "operationId": "dashboard_trend_api_k8s_clusters__cluster_id__waf_dashboard_trend_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-attacks": {
+ "get": {
+ "summary": "Dashboard Top Attacks",
+ "description": "Top attack types by event count.",
+ "operationId": "dashboard_top_attacks_api_k8s_clusters__cluster_id__waf_dashboard_top_attacks_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 50,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-ips": {
+ "get": {
+ "summary": "Dashboard Top Ips",
+ "description": "Top source IPs by blocked event count.",
+ "operationId": "dashboard_top_ips_api_k8s_clusters__cluster_id__waf_dashboard_top_ips_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 50,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-uris": {
+ "get": {
+ "summary": "Dashboard Top Uris",
+ "description": "Top attacked URIs.",
+ "operationId": "dashboard_top_uris_api_k8s_clusters__cluster_id__waf_dashboard_top_uris_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 50,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-policies": {
+ "get": {
+ "summary": "Dashboard Top Policies",
+ "description": "Top policies by hit count \u2014 mirrors NIM's 'Top WAF Policies' panel.",
+ "operationId": "dashboard_top_policies_api_k8s_clusters__cluster_id__waf_dashboard_top_policies_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 50,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/request-methods": {
+ "get": {
+ "summary": "Dashboard Request Methods",
+ "description": "HTTP method distribution \u2014 mirrors NIM's 'Request Methods' panel.",
+ "operationId": "dashboard_request_methods_api_k8s_clusters__cluster_id__waf_dashboard_request_methods_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/severity": {
+ "get": {
+ "summary": "Dashboard Severity",
+ "description": "Violation-rating distribution \u2014 mirrors NIM's 'Severity' panel.",
+ "operationId": "dashboard_severity_api_k8s_clusters__cluster_id__waf_dashboard_severity_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-signatures": {
+ "get": {
+ "summary": "Dashboard Top Signatures",
+ "description": "Top triggered signature names \u2014 mirrors NIM's 'Top Signatures' panel.",
+ "operationId": "dashboard_top_signatures_api_k8s_clusters__cluster_id__waf_dashboard_top_signatures_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 50,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-instances": {
+ "get": {
+ "summary": "Dashboard Top Instances",
+ "description": "Top virtual servers (instances) by hit count \u2014 mirrors NIM's 'Top Attacked Instances' panel.",
+ "operationId": "dashboard_top_instances_api_k8s_clusters__cluster_id__waf_dashboard_top_instances_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "24h",
+ "title": "Time Range"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "maximum": 50,
+ "minimum": 1,
+ "default": 10,
+ "title": "Limit"
+ }
+ },
+ {
+ "name": "outcome",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)",
+ "title": "Outcome"
+ },
+ "description": "Filter by WAF outcome (REJECTED|ALERTED|PASSED)"
+ },
+ {
+ "name": "policy_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by policy name (exact)",
+ "title": "Policy Name"
+ },
+ "description": "Filter by policy name (exact)"
+ },
+ {
+ "name": "vs_name",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by virtual server / instance name (exact)",
+ "title": "Vs Name"
+ },
+ "description": "Filter by virtual server / instance name (exact)"
+ },
+ {
+ "name": "ip_client",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by client IP address (exact)",
+ "title": "Ip Client"
+ },
+ "description": "Filter by client IP address (exact)"
+ },
+ {
+ "name": "attack_type",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by attack type (substring)",
+ "title": "Attack Type"
+ },
+ "description": "Filter by attack type (substring)"
+ },
+ {
+ "name": "method",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)",
+ "title": "Method"
+ },
+ "description": "Filter by HTTP method (GET|POST|etc., case-insensitive)"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-subviolations": {
+ "get": {
+ "summary": "Dashboard Top Subviolations",
+ "description": "Top sub-violations parsed from NAP events (e.g. 'Host header contains IP address').",
+ "operationId": "dashboard_top_subviolations_api_k8s_clusters__cluster_id__waf_dashboard_top_subviolations_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ },
+ {
+ "name": "hours",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 24,
+ "title": "Hours"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 10,
+ "title": "Limit"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/top-geolocations": {
+ "get": {
+ "summary": "Dashboard Top Geolocations",
+ "description": "Top attacker geolocations with lat/lon for world-map rendering.",
+ "operationId": "dashboard_top_geolocations_api_k8s_clusters__cluster_id__waf_dashboard_top_geolocations_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Namespace"
+ }
+ },
+ {
+ "name": "hours",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 24,
+ "title": "Hours"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 15,
+ "title": "Limit"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard/support-id": {
+ "get": {
+ "summary": "Dashboard Support Id",
+ "description": "Look up a specific WAF event by support ID.",
+ "operationId": "dashboard_support_id_api_k8s_clusters__cluster_id__waf_dashboard_support_id_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "support_id",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Support Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/panels/templates": {
+ "get": {
+ "summary": "List Templates",
+ "description": "Return all available query templates with descriptions.",
+ "operationId": "list_templates_api_k8s_clusters__cluster_id__waf_panels_templates_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/panels": {
+ "get": {
+ "summary": "List Panels",
+ "operationId": "list_panels_api_k8s_clusters__cluster_id__waf_panels_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "tab_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tab Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create Panel",
+ "operationId": "create_panel_api_k8s_clusters__cluster_id__waf_panels_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PanelCreate"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/panels/{panel_id}": {
+ "put": {
+ "summary": "Update Panel",
+ "operationId": "update_panel_api_k8s_clusters__cluster_id__waf_panels__panel_id__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "panel_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Panel Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/PanelUpdate"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "summary": "Delete Panel",
+ "operationId": "delete_panel_api_k8s_clusters__cluster_id__waf_panels__panel_id__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "panel_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Panel Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/panels/{panel_id}/data": {
+ "get": {
+ "summary": "Panel Data",
+ "description": "Execute the panel's query template against ClickHouse and return chart data.",
+ "operationId": "panel_data_api_k8s_clusters__cluster_id__waf_panels__panel_id__data_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "panel_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Panel Id"
+ }
+ },
+ {
+ "name": "time_range",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "7d",
+ "title": "Time Range"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard-tabs": {
+ "get": {
+ "summary": "List Tabs",
+ "operationId": "list_tabs_api_k8s_clusters__cluster_id__waf_dashboard_tabs_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create Tab",
+ "operationId": "create_tab_api_k8s_clusters__cluster_id__waf_dashboard_tabs_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TabCreate"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/dashboard-tabs/{tab_id}": {
+ "patch": {
+ "summary": "Rename Tab",
+ "operationId": "rename_tab_api_k8s_clusters__cluster_id__waf_dashboard_tabs__tab_id__patch",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "tab_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Tab Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/TabUpdate"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "summary": "Delete Tab",
+ "operationId": "delete_tab_api_k8s_clusters__cluster_id__waf_dashboard_tabs__tab_id__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "tab_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Tab Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/gateway-classes": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "List Gateway Classes",
+ "operationId": "list_gateway_classes_api_k8s_clusters__cluster_id__waf_gateway_classes_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Create Gateway Class",
+ "operationId": "create_gateway_class_api_k8s_clusters__cluster_id__waf_gateway_classes_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GatewayClassCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/gateway-classes/{name}": {
+ "delete": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Delete Gateway Class",
+ "operationId": "delete_gateway_class_api_k8s_clusters__cluster_id__waf_gateway_classes__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/gateways": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "List Gateways",
+ "operationId": "list_gateways_api_k8s_clusters__cluster_id__waf_gateways_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Create Gateway",
+ "operationId": "create_gateway_api_k8s_clusters__cluster_id__waf_gateways_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GatewayCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/gateways/{name}": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Get Gateway",
+ "operationId": "get_gateway_api_k8s_clusters__cluster_id__waf_gateways__name__get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Update Gateway",
+ "operationId": "update_gateway_api_k8s_clusters__cluster_id__waf_gateways__name__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/GatewayUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Delete Gateway",
+ "operationId": "delete_gateway_api_k8s_clusters__cluster_id__waf_gateways__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/security-profiles": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "List Security Profiles",
+ "operationId": "list_security_profiles_api_k8s_clusters__cluster_id__waf_security_profiles_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Create Security Profile",
+ "operationId": "create_security_profile_api_k8s_clusters__cluster_id__waf_security_profiles_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafSecurityProfileCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/security-profiles/{name}": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Get Security Profile",
+ "operationId": "get_security_profile_api_k8s_clusters__cluster_id__waf_security_profiles__name__get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Update Security Profile",
+ "operationId": "update_security_profile_api_k8s_clusters__cluster_id__waf_security_profiles__name__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/WafSecurityProfileUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Delete Security Profile",
+ "operationId": "delete_security_profile_api_k8s_clusters__cluster_id__waf_security_profiles__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/httproutes": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "List Httproutes",
+ "operationId": "list_httproutes_api_k8s_clusters__cluster_id__waf_httproutes_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Create Httproute",
+ "operationId": "create_httproute_api_k8s_clusters__cluster_id__waf_httproutes_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPRouteCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/httproutes/{name}": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Get Httproute",
+ "operationId": "get_httproute_api_k8s_clusters__cluster_id__waf_httproutes__name__get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Update Httproute",
+ "operationId": "update_httproute_api_k8s_clusters__cluster_id__waf_httproutes__name__put",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPRouteUpdateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Delete Httproute",
+ "operationId": "delete_httproute_api_k8s_clusters__cluster_id__waf_httproutes__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/reference-grants": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "List Reference Grants",
+ "operationId": "list_reference_grants_api_k8s_clusters__cluster_id__waf_reference_grants_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Create Reference Grant",
+ "operationId": "create_reference_grant_api_k8s_clusters__cluster_id__waf_reference_grants_post",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ReferenceGrantCreateRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/reference-grants/{name}": {
+ "delete": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Delete Reference Grant",
+ "operationId": "delete_reference_grant_api_k8s_clusters__cluster_id__waf_reference_grants__name__delete",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "name",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "default": "default",
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/k8s/clusters/{cluster_id}/waf/gateway-topology": {
+ "get": {
+ "tags": [
+ "k8s-waf-gateway"
+ ],
+ "summary": "Get Gateway Topology",
+ "description": "Return all Gateway API + WAF resources together so the UI can build the binding graph.",
+ "operationId": "get_gateway_topology_api_k8s_clusters__cluster_id__waf_gateway_topology_get",
+ "parameters": [
+ {
+ "name": "cluster_id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer",
+ "title": "Cluster Id"
+ }
+ },
+ {
+ "name": "namespace",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {}
+ }
+ }
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/projects/{project_id}/bare-metal/hosts": {
"get": {
"tags": [
@@ -31586,6 +36234,40 @@
],
"title": "AssignedLabelsRequest"
},
+ "BackendRefModel": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "port": {
+ "type": "integer",
+ "title": "Port"
+ },
+ "namespace": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Namespace"
+ },
+ "weight": {
+ "type": "integer",
+ "title": "Weight",
+ "default": 1
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "port"
+ ],
+ "title": "BackendRefModel"
+ },
"BackupCreateRequest": {
"properties": {
"passphrase": {
@@ -46616,6 +51298,135 @@
"title": "FleetOperatorHealth",
"description": "Health summary for a single operator/cluster in the fleet view."
},
+ "GatewayClassCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "controller_name": {
+ "type": "string",
+ "title": "Controller Name",
+ "default": "f5.com/default-f5-cne-controller"
+ },
+ "description": {
+ "type": "string",
+ "title": "Description",
+ "default": "F5 BIG-IP Kubernetes Gateway"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "GatewayClassCreateRequest"
+ },
+ "GatewayCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "default": "default"
+ },
+ "gateway_class_name": {
+ "type": "string",
+ "title": "Gateway Class Name",
+ "default": "f5-gatewayclass"
+ },
+ "listeners": {
+ "items": {
+ "$ref": "#/components/schemas/ListenerModel"
+ },
+ "type": "array",
+ "title": "Listeners"
+ },
+ "addresses": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Addresses",
+ "default": []
+ },
+ "waf_profile_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Waf Profile Name"
+ },
+ "annotations": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object",
+ "title": "Annotations",
+ "default": {}
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "listeners"
+ ],
+ "title": "GatewayCreateRequest"
+ },
+ "GatewayUpdateRequest": {
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "default": "default"
+ },
+ "listeners": {
+ "items": {
+ "$ref": "#/components/schemas/ListenerModel"
+ },
+ "type": "array",
+ "title": "Listeners"
+ },
+ "addresses": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Addresses",
+ "default": []
+ },
+ "waf_profile_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Waf Profile Name"
+ },
+ "annotations": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object",
+ "title": "Annotations",
+ "default": {}
+ }
+ },
+ "type": "object",
+ "required": [
+ "listeners"
+ ],
+ "title": "GatewayUpdateRequest"
+ },
"GitSourceValidation": {
"properties": {
"git_url": {
@@ -46635,6 +51446,156 @@
"title": "GitSourceValidation",
"description": "Schema for git source validation"
},
+ "HTTPRouteCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "default": "default"
+ },
+ "parent_gateway_name": {
+ "type": "string",
+ "title": "Parent Gateway Name"
+ },
+ "parent_gateway_namespace": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Parent Gateway Namespace"
+ },
+ "parent_gateway_section_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Parent Gateway Section Name"
+ },
+ "hostnames": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Hostnames",
+ "default": []
+ },
+ "rules": {
+ "items": {
+ "$ref": "#/components/schemas/HTTPRouteRuleModel"
+ },
+ "type": "array",
+ "title": "Rules"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "parent_gateway_name",
+ "rules"
+ ],
+ "title": "HTTPRouteCreateRequest"
+ },
+ "HTTPRouteRuleModel": {
+ "properties": {
+ "matches": {
+ "items": {
+ "$ref": "#/components/schemas/RouteMatchModel"
+ },
+ "type": "array",
+ "title": "Matches",
+ "default": []
+ },
+ "backend_refs": {
+ "items": {
+ "$ref": "#/components/schemas/BackendRefModel"
+ },
+ "type": "array",
+ "title": "Backend Refs"
+ },
+ "filters": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "Filters",
+ "default": []
+ }
+ },
+ "type": "object",
+ "required": [
+ "backend_refs"
+ ],
+ "title": "HTTPRouteRuleModel"
+ },
+ "HTTPRouteUpdateRequest": {
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "default": "default"
+ },
+ "parent_gateway_name": {
+ "type": "string",
+ "title": "Parent Gateway Name"
+ },
+ "parent_gateway_namespace": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Parent Gateway Namespace"
+ },
+ "parent_gateway_section_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Parent Gateway Section Name"
+ },
+ "hostnames": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array",
+ "title": "Hostnames",
+ "default": []
+ },
+ "rules": {
+ "items": {
+ "$ref": "#/components/schemas/HTTPRouteRuleModel"
+ },
+ "type": "array",
+ "title": "Rules"
+ }
+ },
+ "type": "object",
+ "required": [
+ "parent_gateway_name",
+ "rules"
+ ],
+ "title": "HTTPRouteUpdateRequest"
+ },
"HTTPValidationError": {
"properties": {
"detail": {
@@ -47869,6 +52830,79 @@
],
"title": "LinkClusterRequest"
},
+ "ListenerModel": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "protocol": {
+ "type": "string",
+ "title": "Protocol",
+ "default": "HTTP"
+ },
+ "port": {
+ "type": "integer",
+ "title": "Port",
+ "default": 80
+ },
+ "allowed_routes_from": {
+ "type": "string",
+ "title": "Allowed Routes From",
+ "default": "Same"
+ },
+ "allowed_routes_selector": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Allowed Routes Selector"
+ },
+ "tls_mode": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tls Mode"
+ },
+ "tls_cert_ref_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tls Cert Ref Name"
+ },
+ "tls_cert_ref_namespace": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tls Cert Ref Namespace"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "ListenerModel"
+ },
"LlmFilterDataResponse": {
"properties": {
"available": {
@@ -50503,6 +55537,162 @@
"title": "OpenTofuActionRequest",
"description": "Schema for OpenTofu actions"
},
+ "PanelCreate": {
+ "properties": {
+ "title": {
+ "type": "string",
+ "title": "Title"
+ },
+ "chart_type": {
+ "type": "string",
+ "title": "Chart Type",
+ "default": "bar"
+ },
+ "query_template": {
+ "type": "string",
+ "title": "Query Template"
+ },
+ "time_range": {
+ "type": "string",
+ "title": "Time Range",
+ "default": "7d"
+ },
+ "width": {
+ "type": "string",
+ "title": "Width",
+ "default": "full"
+ },
+ "panel_order": {
+ "type": "integer",
+ "title": "Panel Order",
+ "default": 0
+ },
+ "tab_id": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tab Id"
+ },
+ "extra_config": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Extra Config"
+ }
+ },
+ "type": "object",
+ "required": [
+ "title",
+ "query_template"
+ ],
+ "title": "PanelCreate"
+ },
+ "PanelUpdate": {
+ "properties": {
+ "title": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Title"
+ },
+ "chart_type": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Chart Type"
+ },
+ "query_template": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Query Template"
+ },
+ "time_range": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Time Range"
+ },
+ "width": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Width"
+ },
+ "panel_order": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Panel Order"
+ },
+ "tab_id": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Tab Id"
+ },
+ "extra_config": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Extra Config"
+ }
+ },
+ "type": "object",
+ "title": "PanelUpdate"
+ },
"PlatformCapabilities": {
"properties": {
"secondary_networks": {
@@ -54963,6 +60153,61 @@
"title": "RecoveryStatusResponse",
"description": "Pre-flight check \u2014 what needs recovery?"
},
+ "ReferenceGrantCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace"
+ },
+ "from_group": {
+ "type": "string",
+ "title": "From Group",
+ "default": "gateway.networking.k8s.io"
+ },
+ "from_kind": {
+ "type": "string",
+ "title": "From Kind",
+ "default": "HTTPRoute"
+ },
+ "from_namespace": {
+ "type": "string",
+ "title": "From Namespace"
+ },
+ "to_group": {
+ "type": "string",
+ "title": "To Group",
+ "default": ""
+ },
+ "to_kind": {
+ "type": "string",
+ "title": "To Kind",
+ "default": "Service"
+ },
+ "to_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "To Name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "namespace",
+ "from_namespace"
+ ],
+ "title": "ReferenceGrantCreateRequest",
+ "description": "Allow cross-namespace references (e.g. HTTPRoute in ns-A referencing Service in ns-B)."
+ },
"RegisterAllHostsResponse": {
"properties": {
"created": {
@@ -56416,6 +61661,40 @@
],
"title": "RollupOut"
},
+ "RouteMatchModel": {
+ "properties": {
+ "path_type": {
+ "type": "string",
+ "title": "Path Type",
+ "default": "PathPrefix"
+ },
+ "path_value": {
+ "type": "string",
+ "title": "Path Value",
+ "default": "/"
+ },
+ "headers": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "Headers",
+ "default": []
+ },
+ "query_params": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "Query Params",
+ "default": []
+ }
+ },
+ "type": "object",
+ "title": "RouteMatchModel"
+ },
"RshimInstallQueuedResponse": {
"properties": {
"dpu_id": {
@@ -59849,6 +65128,32 @@
"title": "TMMDebugTmctlRequest",
"description": "Structured tmctl query \u2014 builds the command and parses tabular output."
},
+ "TabCreate": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "TabCreate"
+ },
+ "TabUpdate": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "title": "TabUpdate"
+ },
"TargetOut": {
"properties": {
"id": {
@@ -61715,6 +67020,420 @@
],
"title": "VocabularyResponse"
},
+ "WafLogConfCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace"
+ },
+ "spec": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Spec"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "namespace",
+ "spec"
+ ],
+ "title": "WafLogConfCreateRequest"
+ },
+ "WafLogConfListResponse": {
+ "properties": {
+ "log_confs": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "Log Confs"
+ },
+ "count": {
+ "type": "integer",
+ "title": "Count"
+ }
+ },
+ "type": "object",
+ "required": [
+ "log_confs",
+ "count"
+ ],
+ "title": "WafLogConfListResponse",
+ "description": "GET /waf/logconfs \u2014 APLogConf list envelope."
+ },
+ "WafOperationResponse": {
+ "properties": {
+ "success": {
+ "type": "boolean",
+ "title": "Success",
+ "default": true
+ },
+ "message": {
+ "type": "string",
+ "title": "Message"
+ }
+ },
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "title": "WafOperationResponse",
+ "description": "Delete responses from KubernetesService.delete_resource."
+ },
+ "WafPolicyCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace"
+ },
+ "spec": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Spec"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "namespace",
+ "spec"
+ ],
+ "title": "WafPolicyCreateRequest"
+ },
+ "WafPolicyListResponse": {
+ "properties": {
+ "policies": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "Policies"
+ },
+ "count": {
+ "type": "integer",
+ "title": "Count"
+ }
+ },
+ "type": "object",
+ "required": [
+ "policies",
+ "count"
+ ],
+ "title": "WafPolicyListResponse",
+ "description": "GET /waf/policies \u2014 APPolicy list envelope."
+ },
+ "WafPolicyUpdateRequest": {
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "title": "Namespace"
+ },
+ "spec": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Spec"
+ }
+ },
+ "type": "object",
+ "required": [
+ "namespace",
+ "spec"
+ ],
+ "title": "WafPolicyUpdateRequest"
+ },
+ "WafRecompileResponse": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "title": "Message"
+ },
+ "resource": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Resource"
+ }
+ },
+ "type": "object",
+ "required": [
+ "message"
+ ],
+ "title": "WafRecompileResponse",
+ "description": "POST /waf/policies/{name}/recompile."
+ },
+ "WafResource": {
+ "properties": {
+ "apiVersion": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Apiversion"
+ },
+ "kind": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Kind"
+ },
+ "metadata": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Metadata"
+ },
+ "spec": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Spec"
+ },
+ "status": {
+ "anyOf": [
+ {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Status"
+ }
+ },
+ "additionalProperties": true,
+ "type": "object",
+ "title": "WafResource",
+ "description": "A single appprotect.f5.com/v1 custom resource, returned verbatim from the cluster.\n\nExtra keys are allowed so the raw Kubernetes object (arbitrary CRD spec/status\nfields, managedFields, etc.) passes through the response model unchanged."
+ },
+ "WafSecurityLogsResponse": {
+ "properties": {
+ "entries": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "Entries"
+ },
+ "total": {
+ "type": "integer",
+ "title": "Total"
+ },
+ "source_endpoint": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Source Endpoint"
+ },
+ "cr_kind": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Cr Kind"
+ },
+ "cr_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Cr Name"
+ },
+ "all_endpoints": {
+ "anyOf": [
+ {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "All Endpoints"
+ },
+ "source": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Source"
+ },
+ "error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Error"
+ },
+ "warning": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Warning"
+ }
+ },
+ "type": "object",
+ "required": [
+ "entries",
+ "total"
+ ],
+ "title": "WafSecurityLogsResponse",
+ "description": "GET /waf/security-logs \u2014 security log entries plus resolution metadata.\n\nCovers all return branches (ClickHouse, resolved syslog endpoint, and the\nno-endpoint / unparseable-endpoint fallbacks), so most metadata fields are\noptional."
+ },
+ "WafSecurityProfileCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "default": "default"
+ },
+ "policy_name": {
+ "type": "string",
+ "title": "Policy Name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "policy_name"
+ ],
+ "title": "WafSecurityProfileCreateRequest",
+ "description": "Create an F5BigWebSecurityProfile bridging a named APPolicy to Gateway API."
+ },
+ "WafSecurityProfileUpdateRequest": {
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "title": "Namespace",
+ "default": "default"
+ },
+ "policy_name": {
+ "type": "string",
+ "title": "Policy Name"
+ }
+ },
+ "type": "object",
+ "required": [
+ "policy_name"
+ ],
+ "title": "WafSecurityProfileUpdateRequest"
+ },
+ "WafSignaturesUpdateRequest": {
+ "properties": {
+ "namespace": {
+ "type": "string",
+ "title": "Namespace"
+ },
+ "spec": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Spec"
+ }
+ },
+ "type": "object",
+ "required": [
+ "namespace",
+ "spec"
+ ],
+ "title": "WafSignaturesUpdateRequest"
+ },
+ "WafUserSigCreateRequest": {
+ "properties": {
+ "name": {
+ "type": "string",
+ "title": "Name"
+ },
+ "namespace": {
+ "type": "string",
+ "title": "Namespace"
+ },
+ "spec": {
+ "additionalProperties": true,
+ "type": "object",
+ "title": "Spec"
+ }
+ },
+ "type": "object",
+ "required": [
+ "name",
+ "namespace",
+ "spec"
+ ],
+ "title": "WafUserSigCreateRequest"
+ },
+ "WafUserSigListResponse": {
+ "properties": {
+ "user_sigs": {
+ "items": {
+ "additionalProperties": true,
+ "type": "object"
+ },
+ "type": "array",
+ "title": "User Sigs"
+ },
+ "count": {
+ "type": "integer",
+ "title": "Count"
+ }
+ },
+ "type": "object",
+ "required": [
+ "user_sigs",
+ "count"
+ ],
+ "title": "WafUserSigListResponse",
+ "description": "GET /waf/usersigs \u2014 APUserSig list envelope."
+ },
"routes__bare_metal_hosts__DiscoveryTriggerResponse": {
"properties": {
"host_id": {
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 057eff8d..47f293a0 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -57,5 +57,8 @@ Jinja2==3.1.6
# Rate limiting
slowapi==0.1.9
+# ClickHouse client for WAF dashboard analytics (optional — degrades gracefully if not installed)
+clickhouse-connect==0.8.17
+
# Process metrics (CPU/RAM/network for self-monitoring endpoint)
psutil==6.1.1
diff --git a/backend/routes/k8s/__init__.py b/backend/routes/k8s/__init__.py
index ea1cfdf9..59bc9e74 100644
--- a/backend/routes/k8s/__init__.py
+++ b/backend/routes/k8s/__init__.py
@@ -23,6 +23,12 @@
from routes.k8s.tmm_debug import router as tmm_debug_router
from routes.k8s.topology import router as topology_router
from routes.k8s.tunnels import router as tunnels_router
+from routes.k8s.waf_dashboard import router as waf_dashboard_router
+from routes.k8s.waf_dashboard_tabs import router as waf_dashboard_tabs_router
+from routes.k8s.waf_gateway import router as waf_gateway_router
+from routes.k8s.waf_logs import router as waf_logs_router
+from routes.k8s.waf_panels import router as waf_panels_router
+from routes.k8s.waf_policies import router as waf_policies_router
__all__ = [
"clusters_router",
@@ -35,4 +41,10 @@
"tunnels_router",
"recovery_router",
"topology_router",
+ "waf_dashboard_router",
+ "waf_dashboard_tabs_router",
+ "waf_gateway_router",
+ "waf_panels_router",
+ "waf_logs_router",
+ "waf_policies_router",
]
diff --git a/backend/routes/k8s/waf_dashboard.py b/backend/routes/k8s/waf_dashboard.py
new file mode 100644
index 00000000..ebc50463
--- /dev/null
+++ b/backend/routes/k8s/waf_dashboard.py
@@ -0,0 +1,931 @@
+"""
+WAF Dashboard analytics routes.
+
+Queries ClickHouse for aggregated WAF event data and returns it in
+recharts-friendly shapes. All queries are scoped to cluster_id and
+an optional time range.
+
+Endpoints:
+ GET /api/k8s/clusters/{id}/waf/dashboard/status — availability check
+ GET /api/k8s/clusters/{id}/waf/dashboard/summary — KPI numbers
+ GET /api/k8s/clusters/{id}/waf/dashboard/trend — time-bucketed event counts
+ GET /api/k8s/clusters/{id}/waf/dashboard/top-attacks
+ GET /api/k8s/clusters/{id}/waf/dashboard/top-ips
+ GET /api/k8s/clusters/{id}/waf/dashboard/top-uris
+"""
+from __future__ import annotations
+
+import logging
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Query
+
+from core.errors import handle_route_errors
+from routes.auth import require_viewer
+from services.clickhouse import CLICKHOUSE_DB as _DB
+from services.clickhouse import get_clickhouse
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api")
+
+
+# Supported time ranges → hours
+_RANGE_HOURS: dict[str, int] = {
+ "1h": 1,
+ "24h": 24,
+ "7d": 7 * 24,
+ "30d": 30 * 24,
+}
+
+
+def _hours(time_range: str) -> int:
+ return _RANGE_HOURS.get(time_range, 24)
+
+
+def _build_filter_conditions(
+ params: dict,
+ outcome: str | None = None,
+ policy_name: str | None = None,
+ vs_name: str | None = None,
+ ip_client: str | None = None,
+ attack_type: str | None = None,
+ method: str | None = None,
+) -> list[str]:
+ """Return extra WHERE clauses for the global dashboard filter and populate params."""
+ conditions: list[str] = []
+ if outcome:
+ conditions.append("outcome = {g_outcome:String}")
+ params["g_outcome"] = outcome.upper()
+ if policy_name:
+ conditions.append("policy_name = {g_policy:String}")
+ params["g_policy"] = policy_name
+ if vs_name:
+ conditions.append("vs_name = {g_vs:String}")
+ params["g_vs"] = vs_name
+ if ip_client:
+ conditions.append("ip_client = {g_ip:String}")
+ params["g_ip"] = ip_client
+ if attack_type:
+ conditions.append("positionCaseInsensitive(attack_type, {g_atk:String}) > 0")
+ params["g_atk"] = attack_type
+ if method:
+ conditions.append("upper(method) = {g_method:String}")
+ params["g_method"] = method.upper()
+ return conditions
+
+
+# Shared Query annotation for the six global dashboard filter params (no default in Query — use = None at call site)
+_G_OUTCOME = Query(description="Filter by WAF outcome (REJECTED|ALERTED|PASSED)")
+_G_POLICY = Query(description="Filter by policy name (exact)")
+_G_VS = Query(description="Filter by virtual server / instance name (exact)")
+_G_IP = Query(description="Filter by client IP address (exact)")
+_G_ATTACK_TYPE = Query(description="Filter by attack type (substring)")
+_G_METHOD = Query(description="Filter by HTTP method (GET|POST|etc., case-insensitive)")
+
+
+def _unavailable() -> dict:
+ return {
+ "available": False,
+ "reason": (
+ "ClickHouse is not configured. Set CLICKHOUSE_URL in the environment "
+ "and ensure ClickHouse is running."
+ ),
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/status",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("check WAF dashboard status")
+def dashboard_status(cluster_id: int):
+ """Return whether ClickHouse is reachable and has data for this cluster."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ count = ch.count_events(cluster_id, hours=30 * 24)
+ return {"available": True, "total_events_30d": count}
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/summary",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF dashboard summary")
+def dashboard_summary(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Return KPI numbers: total, rejected_pct, top_attack_type, unique_ips."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+
+ rows = ch.query(
+ f"""
+ SELECT
+ count() AS total,
+ countIf(outcome = 'REJECTED') AS rejected,
+ countIf(outcome = 'ALERTED') AS alerted,
+ countIf(outcome = 'PASSED') AS passed,
+ uniqExact(ip_client) AS unique_ips,
+ topK(1)(attack_type)[1] AS top_attack_type
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND attack_type != 'N/A'
+ OR (cluster_id = {{cid:UInt32}} AND ts >= now() - INTERVAL {{h:UInt32}} HOUR AND outcome != 'PASSED')
+ """,
+ gf_params,
+ )
+
+ # Simpler, correct query
+ rows = ch.query(
+ f"""
+ SELECT
+ count() AS total,
+ countIf(outcome = 'REJECTED') AS rejected,
+ countIf(outcome = 'ALERTED') AS alerted,
+ countIf(outcome = 'PASSED') AS passed,
+ uniqExact(ip_client) AS unique_ips
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ """,
+ gf_params,
+ )
+
+ top_attack_rows = ch.query(
+ f"""
+ SELECT attack_type, count() AS cnt
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND attack_type != 'N/A'
+ AND outcome = 'REJECTED'
+ GROUP BY attack_type
+ ORDER BY cnt DESC
+ LIMIT 1
+ """,
+ gf_params,
+ )
+
+ row = rows[0] if rows else {}
+ total = int(row.get("total", 0))
+ rejected = int(row.get("rejected", 0))
+ return {
+ "available": True,
+ "time_range": time_range,
+ "total": total,
+ "rejected": rejected,
+ "alerted": int(row.get("alerted", 0)),
+ "passed": int(row.get("passed", 0)),
+ "rejected_pct": round(rejected / total * 100, 1) if total else 0.0,
+ "unique_ips": int(row.get("unique_ips", 0)),
+ "top_attack_type": top_attack_rows[0]["attack_type"] if top_attack_rows else "—",
+ "top_attack_count": int(top_attack_rows[0]["cnt"]) if top_attack_rows else 0,
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/trend",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF dashboard trend")
+def dashboard_trend(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """
+ Return time-bucketed event counts split by outcome.
+ Bucket size: 1h for ≤7d ranges, 6h for 30d.
+ Shape: [{ ts, REJECTED, PASSED, ALERTED }, ...]
+ """
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ # Use 6-hour buckets for 30-day view to keep the series manageable
+ bucket_hours = 6 if h > 7 * 24 else 1
+
+ rows = ch.query(
+ f"""
+ SELECT
+ toStartOfInterval(ts, INTERVAL {{bucket:UInt32}} HOUR) AS bucket,
+ countIf(outcome = 'REJECTED') AS REJECTED,
+ countIf(outcome = 'PASSED') AS PASSED,
+ countIf(outcome = 'ALERTED') AS ALERTED
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ GROUP BY bucket
+ ORDER BY bucket
+ """,
+ {**gf_params, "bucket": bucket_hours},
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "bucket_hours": bucket_hours,
+ "series": [
+ {
+ # ISO 8601 format so JS new Date() parses it correctly
+ "ts": str(r["bucket"]).replace(" ", "T"),
+ "REJECTED": int(r.get("REJECTED", 0)),
+ "PASSED": int(r.get("PASSED", 0)),
+ "ALERTED": int(r.get("ALERTED", 0)),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-attacks",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top attack types")
+def dashboard_top_attacks(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ limit: Annotated[int, Query(ge=1, le=50)] = 10,
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Top attack types by event count."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ attack_type,
+ count() AS cnt,
+ countIf(outcome = 'REJECTED') AS rejected
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND attack_type != 'N/A'
+ GROUP BY attack_type
+ ORDER BY cnt DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {**gf_params, "lim": limit},
+ )
+
+ total = sum(int(r["cnt"]) for r in rows)
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "attack_type": r["attack_type"],
+ "count": int(r["cnt"]),
+ "rejected": int(r.get("rejected", 0)),
+ "pct": round(int(r["cnt"]) / total * 100, 1) if total else 0.0,
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-ips",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top source IPs")
+def dashboard_top_ips(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ limit: Annotated[int, Query(ge=1, le=50)] = 10,
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Top source IPs by blocked event count."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ ip_client,
+ count() AS total_hits,
+ countIf(outcome = 'REJECTED') AS blocked_hits,
+ max(ts) AS last_seen
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND ip_client != ''
+ GROUP BY ip_client
+ ORDER BY blocked_hits DESC, total_hits DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {**gf_params, "lim": limit},
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "ip": r["ip_client"],
+ "total_hits": int(r["total_hits"]),
+ "blocked_hits": int(r.get("blocked_hits", 0)),
+ "last_seen": str(r.get("last_seen", "")).replace(" ", "T"),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-uris",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top URIs")
+def dashboard_top_uris(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ limit: Annotated[int, Query(ge=1, le=50)] = 10,
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Top attacked URIs."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ uri,
+ count() AS cnt,
+ countIf(outcome = 'REJECTED') AS rejected,
+ groupUniqArray(3)(attack_type) AS attack_types,
+ max(ts) AS last_seen
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND uri != ''
+ AND outcome = 'REJECTED'
+ GROUP BY uri
+ ORDER BY cnt DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {**gf_params, "lim": limit},
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "uri": r["uri"],
+ "count": int(r["cnt"]),
+ "rejected": int(r.get("rejected", 0)),
+ "attack_types": list(r.get("attack_types", [])),
+ "last_seen": str(r.get("last_seen", "")).replace(" ", "T"),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-policies",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top policies")
+def dashboard_top_policies(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ limit: Annotated[int, Query(ge=1, le=50)] = 10,
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Top policies by hit count — mirrors NIM's 'Top WAF Policies' panel."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ policy_name,
+ count() AS hits,
+ countIf(outcome = 'REJECTED') AS blocked,
+ uniqExact(uri) AS unique_uris,
+ uniqExact(ip_client) AS unique_ips
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND policy_name != ''
+ GROUP BY policy_name
+ ORDER BY hits DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {**gf_params, "lim": limit},
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "policy_name": r["policy_name"],
+ "hits": int(r["hits"]),
+ "blocked": int(r.get("blocked", 0)),
+ "unique_uris": int(r.get("unique_uris", 0)),
+ "unique_ips": int(r.get("unique_ips", 0)),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/request-methods",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF request methods")
+def dashboard_request_methods(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """HTTP method distribution — mirrors NIM's 'Request Methods' panel."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT method, count() AS cnt
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND method != ''
+ GROUP BY method
+ ORDER BY cnt DESC
+ LIMIT 10
+ """,
+ gf_params,
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [{"method": r["method"], "count": int(r["cnt"])} for r in rows],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/severity",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF severity distribution")
+def dashboard_severity(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Violation-rating distribution — mirrors NIM's 'Severity' panel."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ violation_rating,
+ count() AS cnt
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ GROUP BY violation_rating
+ ORDER BY violation_rating DESC
+ LIMIT 10
+ """,
+ gf_params,
+ )
+
+ # Map numeric rating to severity label matching BIG-IP NAP conventions
+ def _label(rating: int) -> str:
+ if rating >= 5:
+ return "Critical"
+ if rating >= 4:
+ return "Error"
+ if rating >= 2:
+ return "Warning"
+ return "Info"
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "rating": int(r["violation_rating"]),
+ "label": _label(int(r["violation_rating"])),
+ "count": int(r["cnt"]),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-signatures",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top signatures")
+def dashboard_top_signatures(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ limit: Annotated[int, Query(ge=1, le=50)] = 10,
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Top triggered signature names — mirrors NIM's 'Top Signatures' panel."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ sig_names,
+ count() AS hits,
+ uniqExact(ip_client) AS unique_ips,
+ uniqExact(uri) AS unique_uris,
+ countIf(outcome = 'REJECTED') AS blocked
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND sig_names != ''
+ AND sig_names != 'N/A'
+ GROUP BY sig_names
+ ORDER BY hits DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {**gf_params, "lim": limit},
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "sig_name": r["sig_names"],
+ "hits": int(r["hits"]),
+ "unique_ips": int(r.get("unique_ips", 0)),
+ "unique_uris": int(r.get("unique_uris", 0)),
+ "blocked": int(r.get("blocked", 0)),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-instances",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top attacked instances")
+def dashboard_top_instances(
+ cluster_id: int,
+ time_range: Annotated[str, Query()] = "24h",
+ limit: Annotated[int, Query(ge=1, le=50)] = 10,
+ outcome: Annotated[str | None, _G_OUTCOME] = None,
+ policy_name: Annotated[str | None, _G_POLICY] = None,
+ vs_name: Annotated[str | None, _G_VS] = None,
+ ip_client: Annotated[str | None, _G_IP] = None,
+ attack_type: Annotated[str | None, _G_ATTACK_TYPE] = None,
+ method: Annotated[str | None, _G_METHOD] = None,
+):
+ """Top virtual servers (instances) by hit count — mirrors NIM's 'Top Attacked Instances' panel."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ h = _hours(time_range)
+ gf_params: dict = {"cid": cluster_id, "h": h}
+ gf_conds = _build_filter_conditions(
+ gf_params, outcome, policy_name, vs_name, ip_client, attack_type, method
+ )
+ gf_extra = (" AND " + " AND ".join(gf_conds)) if gf_conds else ""
+ rows = ch.query(
+ f"""
+ SELECT
+ vs_name,
+ count() AS hits,
+ countIf(outcome = 'REJECTED') AS blocked,
+ uniqExact(uri) AS unique_uris,
+ uniqExact(ip_client) AS unique_ips
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR{gf_extra}
+ AND vs_name != ''
+ GROUP BY vs_name
+ ORDER BY hits DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {**gf_params, "lim": limit},
+ )
+
+ return {
+ "available": True,
+ "time_range": time_range,
+ "items": [
+ {
+ "vs_name": r["vs_name"],
+ "hits": int(r["hits"]),
+ "blocked": int(r.get("blocked", 0)),
+ "unique_uris": int(r.get("unique_uris", 0)),
+ "unique_ips": int(r.get("unique_ips", 0)),
+ }
+ for r in rows
+ ],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-subviolations",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top sub-violations")
+def dashboard_top_subviolations(
+ cluster_id: int,
+ namespace: str,
+ hours: int = 24,
+ limit: int = 10,
+):
+ """Top sub-violations parsed from NAP events (e.g. 'Host header contains IP address')."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ rows = ch.query(
+ f"""
+ SELECT
+ arrayJoin(splitByString(',', sub_violations)) AS sub_violation,
+ count() AS hits,
+ countIf(outcome = 'REJECTED') AS blocked
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR
+ AND sub_violations != ''
+ GROUP BY sub_violation
+ HAVING trimBoth(sub_violation) != ''
+ ORDER BY hits DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {"cid": cluster_id, "h": hours, "lim": limit},
+ )
+ return {
+ "available": True,
+ "items": [
+ {"sub_violation": r["sub_violation"].strip(), "hits": int(r["hits"]), "blocked": int(r["blocked"])}
+ for r in rows if r["sub_violation"].strip()
+ ],
+ }
+
+
+# Prefix-to-country map for attacker IPs generated by the traffic generator
+# Covers the pool in traffic-gen.py; extended with common cloud/hosting prefixes.
+_GEO_MAP = {
+ "185.234": ("Germany", "DE", 51.2, 10.5),
+ "45.142": ("Netherlands", "NL", 52.4, 4.9),
+ "91.108": ("Russia", "RU", 61.5, 105.3),
+ "5.188": ("Russia", "RU", 61.5, 105.3),
+ "31.184": ("Russia", "RU", 61.5, 105.3),
+ "198.235": ("United States", "US", 38.0, -97.0),
+ "212.102": ("United Kingdom", "GB", 55.4, -3.4),
+ "162.55": ("Germany", "DE", 51.2, 10.5),
+ "195.123": ("Luxembourg", "LU", 49.6, 6.1),
+ "89.248": ("Netherlands", "NL", 52.4, 4.9),
+ "103.21": ("China", "CN", 35.9, 104.2),
+ "104.16": ("United States", "US", 38.0, -97.0),
+ "172.67": ("United States", "US", 38.0, -97.0),
+ "10.244": ("Private", "–", 0.0, 0.0),
+ "11.11": ("Private", "–", 0.0, 0.0),
+ "98.234": ("United States", "US", 38.0, -97.0),
+ "76.120": ("United States", "US", 38.0, -97.0),
+ "71.198": ("United States", "US", 38.0, -97.0),
+ "108.14": ("United States", "US", 38.0, -97.0),
+ "67.189": ("United States", "US", 38.0, -97.0),
+ "50.77": ("United States", "US", 38.0, -97.0),
+}
+
+
+def _ip_to_geo(ip: str):
+ for prefix, geo in _GEO_MAP.items():
+ if ip.startswith(prefix + "."):
+ return geo
+ return ("Unknown", "–", 0.0, 0.0)
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/top-geolocations",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF top geolocations")
+def dashboard_top_geolocations(
+ cluster_id: int,
+ namespace: str,
+ hours: int = 24,
+ limit: int = 15,
+):
+ """Top attacker geolocations with lat/lon for world-map rendering."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ rows = ch.query(
+ f"""
+ SELECT
+ coalesce(nullIf(x_forwarded_for, ''), ip_client) AS ip,
+ count() AS hits,
+ countIf(outcome = 'REJECTED') AS blocked
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND ts >= now() - INTERVAL {{h:UInt32}} HOUR
+ GROUP BY ip
+ ORDER BY hits DESC
+ LIMIT {{lim:UInt32}}
+ """,
+ {"cid": cluster_id, "h": hours, "lim": limit},
+ )
+
+ by_country: dict = {}
+ for r in rows:
+ ip = r["ip"]
+ country, code, lat, lon = _ip_to_geo(ip)
+ if code == "–":
+ continue
+ key = code
+ if key in by_country:
+ by_country[key]["hits"] += int(r["hits"])
+ by_country[key]["blocked"] += int(r["blocked"])
+ else:
+ by_country[key] = {
+ "country": country, "code": code,
+ "lat": lat, "lon": lon,
+ "hits": int(r["hits"]), "blocked": int(r["blocked"]),
+ }
+
+ return {
+ "available": True,
+ "items": sorted(by_country.values(), key=lambda x: -x["hits"])[:limit],
+ }
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/dashboard/support-id",
+ dependencies=[Depends(require_viewer)],
+)
+@handle_route_errors("fetch WAF event by support ID")
+def dashboard_support_id(
+ cluster_id: int,
+ support_id: Annotated[str, Query(min_length=1)],
+):
+ """Look up a specific WAF event by support ID."""
+ ch = get_clickhouse()
+ if not ch.available:
+ return _unavailable()
+
+ rows = ch.query(
+ f"""
+ SELECT *
+ FROM {_DB}.waf_events
+ WHERE cluster_id = {{cid:UInt32}}
+ AND support_id = {{sid:String}}
+ LIMIT 1
+ """,
+ {"cid": cluster_id, "sid": support_id},
+ )
+
+ if not rows:
+ from fastapi import HTTPException
+ raise HTTPException(status_code=404, detail=f"No event found for support_id '{support_id}'")
+
+ r = rows[0]
+ return {
+ "available": True,
+ "ts": str(r.get("ts", "")).replace(" ", "T"),
+ "outcome": r.get("outcome", ""),
+ "attack_type": r.get("attack_type", ""),
+ "ip_client": r.get("ip_client", ""),
+ "method": r.get("method", ""),
+ "uri": r.get("uri", ""),
+ "policy_name": r.get("policy_name", ""),
+ "vs_name": r.get("vs_name", ""),
+ "violation_rating": int(r.get("violation_rating", 0)),
+ "sig_ids": r.get("sig_ids", ""),
+ "sig_names": r.get("sig_names", ""),
+ "support_id": r.get("support_id", ""),
+ "namespace": r.get("namespace", ""),
+ "ingest_source": r.get("ingest_source", ""),
+ "raw_message": r.get("raw_message", ""),
+ }
diff --git a/backend/routes/k8s/waf_dashboard_tabs.py b/backend/routes/k8s/waf_dashboard_tabs.py
new file mode 100644
index 00000000..aced92ad
--- /dev/null
+++ b/backend/routes/k8s/waf_dashboard_tabs.py
@@ -0,0 +1,119 @@
+"""WAF Dashboard custom tabs — CRUD for user-defined tabs that group custom panels.
+
+GET /api/k8s/clusters/{id}/waf/dashboard-tabs list tabs (auto-creates a default "Custom" tab on first use)
+POST /api/k8s/clusters/{id}/waf/dashboard-tabs create tab
+PATCH /api/k8s/clusters/{id}/waf/dashboard-tabs/{tab_id} rename tab
+DELETE /api/k8s/clusters/{id}/waf/dashboard-tabs/{tab_id} delete tab + its panels
+"""
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from core.errors import BadRequestError, NotFoundError, handle_route_errors
+from database import get_db
+from models.waf_panels import WafDashboardTab, WafPanel
+from routes.auth import require_operator, require_viewer
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api")
+
+DEFAULT_TAB_NAME = "Custom"
+
+
+class TabCreate(BaseModel):
+ name: str
+
+
+class TabUpdate(BaseModel):
+ name: str
+
+
+def _tab_to_dict(t: WafDashboardTab) -> dict:
+ return {
+ "id": t.id,
+ "cluster_id": t.cluster_id,
+ "name": t.name,
+ "tab_order": t.tab_order,
+ }
+
+
+def _ensure_default_tab(cluster_id: int, db: Session) -> None:
+ """Self-healing migration: if a cluster has legacy panels (tab_id NULL) but
+ no tabs yet, create the default 'Custom' tab and adopt those panels into it.
+ Clusters with zero tabs and zero orphan panels are left alone — the user may
+ have deliberately deleted all their custom tabs."""
+ existing = db.query(WafDashboardTab).filter(WafDashboardTab.cluster_id == cluster_id).count()
+ if existing > 0:
+ return
+ orphans = db.query(WafPanel).filter(WafPanel.cluster_id == cluster_id, WafPanel.tab_id.is_(None)).count()
+ if orphans == 0:
+ return
+ tab = WafDashboardTab(cluster_id=cluster_id, name=DEFAULT_TAB_NAME, tab_order=0)
+ db.add(tab)
+ db.commit()
+ db.refresh(tab)
+ db.query(WafPanel).filter(WafPanel.cluster_id == cluster_id, WafPanel.tab_id.is_(None)).update(
+ {"tab_id": tab.id}, synchronize_session=False,
+ )
+ db.commit()
+
+
+@router.get("/k8s/clusters/{cluster_id}/waf/dashboard-tabs", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list waf dashboard tabs")
+def list_tabs(cluster_id: int, db: Session = Depends(get_db)):
+ _ensure_default_tab(cluster_id, db)
+ tabs = (
+ db.query(WafDashboardTab)
+ .filter(WafDashboardTab.cluster_id == cluster_id)
+ .order_by(WafDashboardTab.tab_order, WafDashboardTab.id)
+ .all()
+ )
+ return {"tabs": [_tab_to_dict(t) for t in tabs]}
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/dashboard-tabs", dependencies=[Depends(require_operator)])
+@handle_route_errors("create waf dashboard tab")
+def create_tab(cluster_id: int, body: TabCreate, db: Session = Depends(get_db)):
+ if not body.name.strip():
+ raise BadRequestError("Tab name is required")
+ _ensure_default_tab(cluster_id, db)
+ max_order = (
+ db.query(WafDashboardTab)
+ .filter(WafDashboardTab.cluster_id == cluster_id)
+ .count()
+ )
+ tab = WafDashboardTab(cluster_id=cluster_id, name=body.name.strip(), tab_order=max_order)
+ db.add(tab)
+ db.commit()
+ db.refresh(tab)
+ return _tab_to_dict(tab)
+
+
+@router.patch("/k8s/clusters/{cluster_id}/waf/dashboard-tabs/{tab_id}", dependencies=[Depends(require_operator)])
+@handle_route_errors("rename waf dashboard tab")
+def rename_tab(cluster_id: int, tab_id: int, body: TabUpdate, db: Session = Depends(get_db)):
+ if not body.name.strip():
+ raise BadRequestError("Tab name is required")
+ tab = db.query(WafDashboardTab).filter(WafDashboardTab.id == tab_id, WafDashboardTab.cluster_id == cluster_id).first()
+ if not tab:
+ raise NotFoundError(f"Tab {tab_id} not found")
+ tab.name = body.name.strip()
+ db.commit()
+ db.refresh(tab)
+ return _tab_to_dict(tab)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/dashboard-tabs/{tab_id}", dependencies=[Depends(require_operator)])
+@handle_route_errors("delete waf dashboard tab")
+def delete_tab(cluster_id: int, tab_id: int, db: Session = Depends(get_db)):
+ tab = db.query(WafDashboardTab).filter(WafDashboardTab.id == tab_id, WafDashboardTab.cluster_id == cluster_id).first()
+ if not tab:
+ raise NotFoundError(f"Tab {tab_id} not found")
+ db.query(WafPanel).filter(WafPanel.cluster_id == cluster_id, WafPanel.tab_id == tab_id).delete(synchronize_session=False)
+ db.delete(tab)
+ db.commit()
+ return {"deleted": tab_id}
diff --git a/backend/routes/k8s/waf_gateway.py b/backend/routes/k8s/waf_gateway.py
new file mode 100644
index 00000000..ad2a6707
--- /dev/null
+++ b/backend/routes/k8s/waf_gateway.py
@@ -0,0 +1,600 @@
+"""
+WAF Gateway API routes — manage Gateway API resources for WAF attachment.
+
+Covers the full binding chain:
+ GatewayClass → Gateway → F5BigWebSecurityProfile → APPolicy
+ ↳ HTTPRoute (traffic rules)
+ ↳ ReferenceGrant (cross-namespace permissions)
+
+All resources are managed directly via KubernetesService.
+"""
+
+import logging
+from typing import Any
+
+import yaml
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from core.errors import NotFoundError, handle_route_errors
+from database import get_db
+from routes.auth import require_cluster_owner, require_viewer
+from services.kubernetes_service import KubernetesService
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["k8s-waf-gateway"])
+
+WSP_API_VERSION = "k8s.f5net.com/v1"
+SECPOLICY_API_VERSION = "gateway.k8s.f5.com/v1alpha1"
+GATEWAY_API_VERSION = "gateway.networking.k8s.io/v1"
+REFGRANT_API_VERSION = "gateway.networking.k8s.io/v1beta1"
+GATEWAYCLASS_API_VERSION = "gateway.networking.k8s.io/v1"
+
+# Annotation key used to bind an F5BigWebSecurityProfile to a Gateway
+WSP_ANNOTATION = "k8s.f5net.com/web-security-profile"
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Request models
+# ──────────────────────────────────────────────────────────────────────────────
+
+class GatewayClassCreateRequest(BaseModel):
+ name: str
+ controller_name: str = "f5.com/default-f5-cne-controller"
+ description: str = "F5 BIG-IP Kubernetes Gateway"
+
+
+class ListenerModel(BaseModel):
+ name: str
+ protocol: str = "HTTP"
+ port: int = 80
+ allowed_routes_from: str = "Same" # Same | All | Selector
+ allowed_routes_selector: dict | None = None
+ tls_mode: str | None = None # Terminate | Passthrough
+ tls_cert_ref_name: str | None = None
+ tls_cert_ref_namespace: str | None = None
+
+
+class GatewayCreateRequest(BaseModel):
+ name: str
+ namespace: str = "default"
+ gateway_class_name: str = "f5-gatewayclass"
+ listeners: list[ListenerModel]
+ addresses: list[str] = [] # IP address strings
+ # WAF binding — optional at creation (can add WSP binding separately)
+ waf_profile_name: str | None = None # F5BigWebSecurityProfile name (same namespace)
+ annotations: dict[str, str] = {}
+
+
+class GatewayUpdateRequest(BaseModel):
+ namespace: str = "default"
+ listeners: list[ListenerModel]
+ addresses: list[str] = []
+ waf_profile_name: str | None = None
+ annotations: dict[str, str] = {}
+
+
+class WafSecurityProfileCreateRequest(BaseModel):
+ """Create an F5BigWebSecurityProfile bridging a named APPolicy to Gateway API."""
+ name: str
+ namespace: str = "default"
+ policy_name: str # APPolicy name (resolved by WAF enforcer — any namespace)
+
+
+class WafSecurityProfileUpdateRequest(BaseModel):
+ namespace: str = "default"
+ policy_name: str
+
+
+class BackendRefModel(BaseModel):
+ name: str
+ port: int
+ namespace: str | None = None
+ weight: int = 1
+
+
+class RouteMatchModel(BaseModel):
+ path_type: str = "PathPrefix" # Exact | PathPrefix | RegularExpression
+ path_value: str = "/"
+ headers: list[dict] = [] # {name, value, type?}
+ query_params: list[dict] = []
+
+
+class HTTPRouteRuleModel(BaseModel):
+ matches: list[RouteMatchModel] = []
+ backend_refs: list[BackendRefModel]
+ filters: list[dict] = []
+
+
+class HTTPRouteCreateRequest(BaseModel):
+ name: str
+ namespace: str = "default"
+ parent_gateway_name: str
+ parent_gateway_namespace: str | None = None # defaults to same namespace as route
+ parent_gateway_section_name: str | None = None
+ hostnames: list[str] = []
+ rules: list[HTTPRouteRuleModel]
+
+
+class HTTPRouteUpdateRequest(BaseModel):
+ namespace: str = "default"
+ parent_gateway_name: str
+ parent_gateway_namespace: str | None = None
+ parent_gateway_section_name: str | None = None
+ hostnames: list[str] = []
+ rules: list[HTTPRouteRuleModel]
+
+
+class ReferenceGrantCreateRequest(BaseModel):
+ """Allow cross-namespace references (e.g. HTTPRoute in ns-A referencing Service in ns-B)."""
+ name: str
+ namespace: str # namespace WHERE the referenced resource lives
+ from_group: str = "gateway.networking.k8s.io"
+ from_kind: str = "HTTPRoute"
+ from_namespace: str # namespace WHERE the referring resource lives
+ to_group: str = ""
+ to_kind: str = "Service"
+ to_name: str | None = None # None = any resource of that kind
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────────────────────
+
+def _find_by_name(resources: list[dict], name: str) -> dict | None:
+ for r in resources:
+ if r.get("metadata", {}).get("name") == name:
+ return r
+ return None
+
+
+def _build_listener(lm: ListenerModel) -> dict:
+ listener: dict[str, Any] = {
+ "name": lm.name,
+ "protocol": lm.protocol,
+ "port": lm.port,
+ }
+ # allowedRoutes
+ if lm.allowed_routes_from == "All":
+ listener["allowedRoutes"] = {"namespaces": {"from": "All"}}
+ elif lm.allowed_routes_from == "Selector" and lm.allowed_routes_selector:
+ listener["allowedRoutes"] = {"namespaces": {"from": "Selector", "selector": lm.allowed_routes_selector}}
+ else:
+ listener["allowedRoutes"] = {"namespaces": {"from": "Same"}}
+ # TLS
+ if lm.tls_mode and lm.tls_cert_ref_name:
+ tls: dict[str, Any] = {"mode": lm.tls_mode}
+ if lm.tls_cert_ref_name:
+ cert_ref: dict[str, Any] = {"name": lm.tls_cert_ref_name, "kind": "Secret"}
+ if lm.tls_cert_ref_namespace:
+ cert_ref["namespace"] = lm.tls_cert_ref_namespace
+ tls["certificateRefs"] = [cert_ref]
+ listener["tls"] = tls
+ return listener
+
+
+def _build_gateway_dict(
+ name: str,
+ namespace: str,
+ gateway_class_name: str,
+ listeners: list[ListenerModel],
+ addresses: list[str],
+ waf_profile_name: str | None,
+ annotations: dict[str, str],
+ resource_version: str | None = None,
+) -> dict:
+ metadata: dict[str, Any] = {"name": name, "namespace": namespace}
+ if resource_version:
+ metadata["resourceVersion"] = resource_version
+ all_annotations = dict(annotations)
+ if waf_profile_name:
+ all_annotations[WSP_ANNOTATION] = waf_profile_name
+ if all_annotations:
+ metadata["annotations"] = all_annotations
+
+ spec: dict[str, Any] = {
+ "gatewayClassName": gateway_class_name,
+ "listeners": [_build_listener(lm) for lm in listeners],
+ }
+ if addresses:
+ spec["addresses"] = [{"type": "IPAddress", "value": a} for a in addresses]
+
+ return {"apiVersion": GATEWAY_API_VERSION, "kind": "Gateway", "metadata": metadata, "spec": spec}
+
+
+def _build_route_rule(rule: HTTPRouteRuleModel) -> dict:
+ r: dict[str, Any] = {}
+ # matches
+ if rule.matches:
+ r["matches"] = []
+ for m in rule.matches:
+ match: dict[str, Any] = {"path": {"type": m.path_type, "value": m.path_value}}
+ if m.headers:
+ match["headers"] = m.headers
+ if m.query_params:
+ match["queryParams"] = m.query_params
+ r["matches"].append(match)
+ # backendRefs
+ r["backendRefs"] = []
+ for b in rule.backend_refs:
+ ref: dict[str, Any] = {"name": b.name, "port": b.port, "weight": b.weight}
+ if b.namespace:
+ ref["namespace"] = b.namespace
+ r["backendRefs"].append(ref)
+ if rule.filters:
+ r["filters"] = rule.filters
+ return r
+
+
+def _build_httproute_dict(
+ name: str,
+ namespace: str,
+ parent_gateway_name: str,
+ parent_gateway_namespace: str | None,
+ parent_gateway_section_name: str | None,
+ hostnames: list[str],
+ rules: list[HTTPRouteRuleModel],
+ resource_version: str | None = None,
+) -> dict:
+ metadata: dict[str, Any] = {"name": name, "namespace": namespace}
+ if resource_version:
+ metadata["resourceVersion"] = resource_version
+
+ parent_ref: dict[str, Any] = {
+ "group": "gateway.networking.k8s.io",
+ "kind": "Gateway",
+ "name": parent_gateway_name,
+ }
+ if parent_gateway_namespace:
+ parent_ref["namespace"] = parent_gateway_namespace
+ if parent_gateway_section_name:
+ parent_ref["sectionName"] = parent_gateway_section_name
+
+ spec: dict[str, Any] = {
+ "parentRefs": [parent_ref],
+ "rules": [_build_route_rule(r) for r in rules],
+ }
+ if hostnames:
+ spec["hostnames"] = hostnames
+
+ return {"apiVersion": GATEWAY_API_VERSION, "kind": "HTTPRoute", "metadata": metadata, "spec": spec}
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# GatewayClass
+# ──────────────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/gateway-classes", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list gateway classes")
+def list_gateway_classes(cluster_id: int, db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "gatewayclass")
+ return {"gateway_classes": items, "count": len(items)}
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/gateway-classes")
+@handle_route_errors("create gateway class")
+def create_gateway_class(
+ cluster_id: int,
+ req: GatewayClassCreateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ body = {
+ "apiVersion": GATEWAYCLASS_API_VERSION,
+ "kind": "GatewayClass",
+ "metadata": {"name": req.name},
+ "spec": {
+ "controllerName": req.controller_name,
+ "description": req.description,
+ },
+ }
+ result = k8s.create_resource(cluster_id, "gatewayclass", yaml.dump(body))
+ return result.get("resource", result)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/gateway-classes/{name}")
+@handle_route_errors("delete gateway class")
+def delete_gateway_class(
+ cluster_id: int, name: str,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ return KubernetesService(db).delete_resource(cluster_id, "gatewayclass", name)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Gateway
+# ──────────────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/gateways", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list gateways")
+def list_gateways(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "gateway", namespace)
+ return {"gateways": items, "count": len(items)}
+
+
+@router.get("/k8s/clusters/{cluster_id}/waf/gateways/{name}", dependencies=[Depends(require_viewer)])
+@handle_route_errors("get gateway")
+def get_gateway(cluster_id: int, name: str, namespace: str = "default", db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "gateway", namespace)
+ gw = _find_by_name(items, name)
+ if not gw:
+ raise NotFoundError("gateway", name)
+ return gw
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/gateways")
+@handle_route_errors("create gateway")
+def create_gateway(
+ cluster_id: int,
+ req: GatewayCreateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ body = _build_gateway_dict(
+ req.name, req.namespace, req.gateway_class_name,
+ req.listeners, req.addresses, req.waf_profile_name, req.annotations,
+ )
+ result = k8s.create_resource(cluster_id, "gateway", yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.put("/k8s/clusters/{cluster_id}/waf/gateways/{name}")
+@handle_route_errors("update gateway")
+def update_gateway(
+ cluster_id: int, name: str,
+ req: GatewayUpdateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ existing = _find_by_name(k8s.get_resources(cluster_id, "gateway", req.namespace), name)
+ if not existing:
+ raise NotFoundError("gateway", name)
+ rv = existing.get("metadata", {}).get("resourceVersion")
+ # Preserve existing gateway class from the live object
+ existing_gc = existing.get("spec", {}).get("gatewayClassName", "f5-gatewayclass")
+ body = _build_gateway_dict(
+ name, req.namespace, existing_gc,
+ req.listeners, req.addresses, req.waf_profile_name, req.annotations, rv,
+ )
+ result = k8s.update_resource(cluster_id, "gateway", name, yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/gateways/{name}")
+@handle_route_errors("delete gateway")
+def delete_gateway(
+ cluster_id: int, name: str, namespace: str = "default",
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ return KubernetesService(db).delete_resource(cluster_id, "gateway", name, namespace)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# F5BigWebSecurityProfile (WAF policy → Gateway bridge)
+# ──────────────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/security-profiles", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list waf security profiles")
+def list_security_profiles(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "f5-big-web-security-profiles", namespace)
+ return {"profiles": items, "count": len(items)}
+
+
+@router.get("/k8s/clusters/{cluster_id}/waf/security-profiles/{name}", dependencies=[Depends(require_viewer)])
+@handle_route_errors("get waf security profile")
+def get_security_profile(cluster_id: int, name: str, namespace: str = "default", db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "f5-big-web-security-profiles", namespace)
+ profile = _find_by_name(items, name)
+ if not profile:
+ raise NotFoundError("f5bigwebsecurityprofile", name)
+ return profile
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/security-profiles")
+@handle_route_errors("create waf security profile")
+def create_security_profile(
+ cluster_id: int,
+ req: WafSecurityProfileCreateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ body = {
+ "apiVersion": WSP_API_VERSION,
+ "kind": "F5BigWebSecurityProfile",
+ "metadata": {"name": req.name, "namespace": req.namespace},
+ "spec": {"policyName": req.policy_name},
+ }
+ result = k8s.create_resource(cluster_id, "f5-big-web-security-profiles", yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.put("/k8s/clusters/{cluster_id}/waf/security-profiles/{name}")
+@handle_route_errors("update waf security profile")
+def update_security_profile(
+ cluster_id: int, name: str,
+ req: WafSecurityProfileUpdateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ existing = _find_by_name(k8s.get_resources(cluster_id, "f5-big-web-security-profiles", req.namespace), name)
+ if not existing:
+ raise NotFoundError("f5bigwebsecurityprofile", name)
+ rv = existing.get("metadata", {}).get("resourceVersion")
+ body = {
+ "apiVersion": WSP_API_VERSION,
+ "kind": "F5BigWebSecurityProfile",
+ "metadata": {"name": name, "namespace": req.namespace, "resourceVersion": rv},
+ "spec": {"policyName": req.policy_name},
+ }
+ result = k8s.update_resource(cluster_id, "f5-big-web-security-profiles", name, yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/security-profiles/{name}")
+@handle_route_errors("delete waf security profile")
+def delete_security_profile(
+ cluster_id: int, name: str, namespace: str = "default",
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ return KubernetesService(db).delete_resource(cluster_id, "f5-big-web-security-profiles", name, namespace)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# HTTPRoute
+# ──────────────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/httproutes", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list httproutes")
+def list_httproutes(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "httproute", namespace)
+ return {"routes": items, "count": len(items)}
+
+
+@router.get("/k8s/clusters/{cluster_id}/waf/httproutes/{name}", dependencies=[Depends(require_viewer)])
+@handle_route_errors("get httproute")
+def get_httproute(cluster_id: int, name: str, namespace: str = "default", db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "httproute", namespace)
+ route = _find_by_name(items, name)
+ if not route:
+ raise NotFoundError("httproute", name)
+ return route
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/httproutes")
+@handle_route_errors("create httproute")
+def create_httproute(
+ cluster_id: int,
+ req: HTTPRouteCreateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ body = _build_httproute_dict(
+ req.name, req.namespace,
+ req.parent_gateway_name, req.parent_gateway_namespace,
+ req.parent_gateway_section_name, req.hostnames, req.rules,
+ )
+ result = k8s.create_resource(cluster_id, "httproute", yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.put("/k8s/clusters/{cluster_id}/waf/httproutes/{name}")
+@handle_route_errors("update httproute")
+def update_httproute(
+ cluster_id: int, name: str,
+ req: HTTPRouteUpdateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ existing = _find_by_name(k8s.get_resources(cluster_id, "httproute", req.namespace), name)
+ if not existing:
+ raise NotFoundError("httproute", name)
+ rv = existing.get("metadata", {}).get("resourceVersion")
+ body = _build_httproute_dict(
+ name, req.namespace,
+ req.parent_gateway_name, req.parent_gateway_namespace,
+ req.parent_gateway_section_name, req.hostnames, req.rules, rv,
+ )
+ result = k8s.update_resource(cluster_id, "httproute", name, yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/httproutes/{name}")
+@handle_route_errors("delete httproute")
+def delete_httproute(
+ cluster_id: int, name: str, namespace: str = "default",
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ return KubernetesService(db).delete_resource(cluster_id, "httproute", name, namespace)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# ReferenceGrant (cross-namespace permissions)
+# ──────────────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/reference-grants", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list reference grants")
+def list_reference_grants(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s = KubernetesService(db)
+ items = k8s.get_resources(cluster_id, "referencegrant", namespace)
+ return {"reference_grants": items, "count": len(items)}
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/reference-grants")
+@handle_route_errors("create reference grant")
+def create_reference_grant(
+ cluster_id: int,
+ req: ReferenceGrantCreateRequest,
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ k8s = KubernetesService(db)
+ to_entry: dict[str, Any] = {"group": req.to_group, "kind": req.to_kind}
+ if req.to_name:
+ to_entry["name"] = req.to_name
+ body = {
+ "apiVersion": REFGRANT_API_VERSION,
+ "kind": "ReferenceGrant",
+ "metadata": {"name": req.name, "namespace": req.namespace},
+ "spec": {
+ "from": [{"group": req.from_group, "kind": req.from_kind, "namespace": req.from_namespace}],
+ "to": [to_entry],
+ },
+ }
+ result = k8s.create_resource(cluster_id, "referencegrant", yaml.dump(body), req.namespace)
+ return result.get("resource", result)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/reference-grants/{name}")
+@handle_route_errors("delete reference grant")
+def delete_reference_grant(
+ cluster_id: int, name: str, namespace: str = "default",
+ db: Session = Depends(get_db),
+ _user=Depends(require_cluster_owner),
+):
+ return KubernetesService(db).delete_resource(cluster_id, "referencegrant", name, namespace)
+
+
+# ──────────────────────────────────────────────────────────────────────────────
+# Convenience: topology view — returns the full binding chain for a cluster
+# ──────────────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/gateway-topology", dependencies=[Depends(require_viewer)])
+@handle_route_errors("get gateway waf topology")
+def get_gateway_topology(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ """Return all Gateway API + WAF resources together so the UI can build the binding graph."""
+ k8s = KubernetesService(db)
+ gateways = k8s.get_resources(cluster_id, "gateway", namespace)
+ httproutes = k8s.get_resources(cluster_id, "httproute", namespace)
+ profiles = k8s.get_resources(cluster_id, "f5-big-web-security-profiles", namespace)
+ policies = k8s.get_resources(cluster_id, "appolicy", namespace)
+ refgrants = k8s.get_resources(cluster_id, "referencegrant", namespace)
+ gateway_classes = k8s.get_resources(cluster_id, "gatewayclass")
+
+ return {
+ "gateway_classes": gateway_classes,
+ "gateways": gateways,
+ "httproutes": httproutes,
+ "security_profiles": profiles,
+ "waf_policies": policies,
+ "reference_grants": refgrants,
+ }
diff --git a/backend/routes/k8s/waf_logs.py b/backend/routes/k8s/waf_logs.py
new file mode 100644
index 00000000..aac09e2a
--- /dev/null
+++ b/backend/routes/k8s/waf_logs.py
@@ -0,0 +1,487 @@
+"""
+WAF Security Logs routes.
+
+Resolves the syslog endpoint chain:
+ APPolicy / F5VirtualServer → SecPolicy → F5BigLogProfile → F5BigHslPub → host:port
+
+Then reads up to `limit` recent log lines from the syslog TCP stream, parses
+NAP's key=value default format, and returns structured entries.
+"""
+
+import logging
+import re
+import socket
+from contextlib import suppress
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Query
+from kubernetes import client as k8s_client
+from kubernetes import stream
+from sqlalchemy.orm import Session
+
+from core.errors import handle_route_errors
+from database import get_db
+from routes.auth import require_viewer
+from schemas.waf import WafSecurityLogsResponse
+from services.clickhouse import CLICKHOUSE_DB as _CLICKHOUSE_DB
+from services.clickhouse import get_clickhouse
+from services.kubernetes import KubernetesService
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api")
+
+# NAP default/splunk log fields we parse and surface
+_NAP_KV_RE = re.compile(r'(\w+)="([^"]*)"')
+
+# Syslog receiver pod label and log directory (matches our fluentd deployment)
+_SYSLOG_POD_LABEL = "app=waf-syslog-receiver"
+_SYSLOG_LOG_DIR = "/var/log/waf-syslog"
+
+_SOCKET_TIMEOUT_S = 3.0
+_READ_CHUNK = 65536
+_MAX_LIMIT = 500
+
+
+def _parse_nap_entry(raw: str) -> dict:
+ """Parse a single NAP syslog line (key=value pairs) into a dict."""
+ entry: dict = {"raw": raw.strip()}
+ for key, val in _NAP_KV_RE.findall(raw):
+ entry[key] = val
+ return entry
+
+
+def _read_logs_from_pod(
+ k8s: KubernetesService,
+ cluster_id: int,
+ namespace: str,
+ limit: int,
+) -> tuple[list[dict], str | None]:
+ """
+ Read NAP security logs from the waf-syslog-receiver pod's log files via kubectl exec.
+ Uses the k8s Python client directly for core Pod API (not in the CRD registry).
+ Returns ([], None) when no receiver pod exists so caller can try TCP fallback.
+ """
+ try:
+ cluster = k8s.get_cluster(cluster_id)
+ api_client = k8s.load_kubeconfig(cluster)
+ core_v1 = k8s_client.CoreV1Api(api_client)
+
+ pod_list = core_v1.list_namespaced_pod(
+ namespace,
+ label_selector=_SYSLOG_POD_LABEL,
+ )
+ if not pod_list.items:
+ return [], None # No receiver pod; caller will try TCP
+
+ pod_name = pod_list.items[0].metadata.name
+
+ # Discover the most recent log file with find (no shell available in container)
+ find_resp = stream.stream(
+ core_v1.connect_get_namespaced_pod_exec,
+ pod_name,
+ namespace,
+ command=["find", _SYSLOG_LOG_DIR, "-name", "security.*.log", "-type", "f"],
+ stderr=True, stdin=False, stdout=True, tty=False,
+ )
+ # Sort candidates and pick the last (most recent by name, which is date-based)
+ candidates = sorted(ln.strip() for ln in find_resp.splitlines() if ln.strip())
+ log_file = candidates[-1] if candidates else f"{_SYSLOG_LOG_DIR}/security.log"
+
+ resp = stream.stream(
+ core_v1.connect_get_namespaced_pod_exec,
+ pod_name,
+ namespace,
+ command=["tail", f"-n{limit}", log_file],
+ stderr=True,
+ stdin=False,
+ stdout=True,
+ tty=False,
+ )
+ lines = [ln for ln in resp.splitlines() if ln.strip()]
+ return [_parse_nap_entry(ln) for ln in lines], None
+ except Exception as exc:
+ logger.debug("Could not read logs from pod: %s", exc, exc_info=True)
+ return [], None # Caller will try TCP fallback
+
+
+def _read_syslog_tcp(host: str, port: int, limit: int) -> tuple[list[dict], str | None]:
+ """Connect to host:port via TCP, read buffered data, return parsed entries."""
+ try:
+ with socket.create_connection((host, port), timeout=_SOCKET_TIMEOUT_S) as sock:
+ sock.settimeout(_SOCKET_TIMEOUT_S)
+ chunks: list[bytes] = []
+ with suppress(TimeoutError, OSError):
+ while True:
+ chunk = sock.recv(_READ_CHUNK)
+ if not chunk:
+ break
+ chunks.append(chunk)
+
+ raw_data = b"".join(chunks).decode("utf-8", errors="replace")
+ lines = [ln for ln in raw_data.splitlines() if ln.strip()]
+ lines = lines[-limit:]
+ return [_parse_nap_entry(ln) for ln in lines], None
+ except ConnectionRefusedError:
+ return [], f"Connection refused to {host}:{port} — syslog server may not be running"
+ except TimeoutError:
+ return [], f"Timed out connecting to {host}:{port}"
+ except OSError as exc:
+ return [], f"Network error connecting to {host}:{port}: {exc}"
+
+
+def _resolve_hslpub_endpoints(
+ k8s: KubernetesService,
+ cluster_id: int,
+ namespace: str,
+ pub_name: str,
+) -> list[str]:
+ """Resolve a F5BigLogHslpub name → list of 'host:port' endpoint strings."""
+ try:
+ pubs = k8s.get_resources(cluster_id, "f5bigloghslpub", namespace)
+ for pub in pubs:
+ if pub.get("metadata", {}).get("name") == pub_name:
+ endpoints: list[str] = []
+ for pool in pub.get("spec", {}).get("pool", []):
+ endpoints.extend(pool.get("endpoint", []))
+ return endpoints
+ except Exception:
+ logger.debug("Could not fetch F5BigLogHslpub %s", pub_name, exc_info=True)
+ return []
+
+
+def _resolve_logprofile_endpoints(
+ k8s: KubernetesService,
+ cluster_id: int,
+ namespace: str,
+ profile_name: str,
+) -> list[str]:
+ """Walk F5BigLogProfile → publisher name → F5BigLogHslpub → endpoints."""
+ try:
+ profiles = k8s.get_resources(cluster_id, "f5biglogprofile", namespace)
+ for profile in profiles:
+ if profile.get("metadata", {}).get("name") == profile_name:
+ spec = profile.get("spec", {})
+ # Top-level publisher field (actual CRD schema)
+ pub_name = spec.get("publisher") or (
+ spec.get("applicationSecurity", {}).get("publisher")
+ or spec.get("network", {}).get("publisher")
+ or spec.get("botDefense", {}).get("publisher")
+ or ""
+ )
+ if pub_name:
+ return _resolve_hslpub_endpoints(k8s, cluster_id, namespace, pub_name)
+ except Exception:
+ logger.debug("Could not fetch F5BigLogProfile %s", profile_name, exc_info=True)
+ return []
+
+
+def _resolve_secpolicy_endpoints(
+ k8s: KubernetesService,
+ cluster_id: int,
+ namespace: str,
+ secpolicy_name: str,
+) -> list[str]:
+ """Walk SecPolicy extensionRefs → F5BigLogProfile → F5BigHslpub → endpoints."""
+ # Try both the CRD plural (secpolicies) and the registry key (bnksecpolicy)
+ for resource_key in ("secpolicies", "bnksecpolicy"):
+ try:
+ policies = k8s.get_resources(cluster_id, resource_key, namespace)
+ for pol in policies:
+ if pol.get("metadata", {}).get("name") == secpolicy_name:
+ # extensionRefs contains F5BigLogProfile references
+ for ref in pol.get("spec", {}).get("extensionRefs", []):
+ if ref.get("kind") == "F5BigLogProfile":
+ profile_ns = ref.get("namespace") or namespace
+ endpoints = _resolve_logprofile_endpoints(
+ k8s, cluster_id, profile_ns, ref["name"]
+ )
+ if endpoints:
+ return endpoints
+ except Exception:
+ logger.debug("Could not fetch %s %s", resource_key, secpolicy_name, exc_info=True)
+ return []
+
+
+def _resolve_endpoints_for_policy(
+ k8s: KubernetesService,
+ cluster_id: int,
+ namespace: str,
+ policy_name: str,
+) -> list[str]:
+ """
+ Given an APPolicy name, find syslog endpoints by walking:
+ 1. SecPolicies whose targetRefs include this APPolicy → extensionRefs → F5BigLogProfile → F5BigHslpub
+ 2. All SecPolicies in namespace (fallback when targetRefs use Gateway-level refs)
+ """
+ endpoints: list[str] = []
+ matched_secpolicies: set[str] = set()
+
+ for resource_key in ("secpolicies", "bnksecpolicy"):
+ try:
+ sec_policies = k8s.get_resources(cluster_id, resource_key, namespace)
+ for pol in sec_policies:
+ pol_name = pol["metadata"]["name"]
+ spec = pol.get("spec", {})
+ # Check targetRefs for direct APPolicy reference
+ for ref in spec.get("targetRefs", []):
+ if ref.get("kind") == "APPolicy" and ref.get("name") == policy_name:
+ matched_secpolicies.add(pol_name)
+ # Also check legacy items[] structure
+ for item in spec.get("items", []):
+ if item.get("kind") in ("F5BigWebSecurityProfile", "APPolicy"):
+ ref_name = item.get("name", "")
+ if ref_name == policy_name or not ref_name:
+ matched_secpolicies.add(pol_name)
+ except Exception:
+ logger.debug("Could not list %s resources", resource_key, exc_info=True)
+
+ # Resolve endpoints from matched SecPolicies
+ for pol_name in matched_secpolicies:
+ ep = _resolve_secpolicy_endpoints(k8s, cluster_id, namespace, pol_name)
+ endpoints.extend(ep)
+
+ if not endpoints:
+ # Fallback: scan all SecPolicies in namespace (covers Gateway-level targetRefs)
+ for resource_key in ("secpolicies", "bnksecpolicy"):
+ try:
+ sec_policies = k8s.get_resources(cluster_id, resource_key, namespace)
+ for pol in sec_policies:
+ pol_name = pol["metadata"]["name"]
+ ep = _resolve_secpolicy_endpoints(k8s, cluster_id, namespace, pol_name)
+ endpoints.extend(ep)
+ except Exception:
+ logger.debug("Could not scan %s resources", resource_key, exc_info=True)
+
+ if not endpoints:
+ # Last resort: any F5BigLogProfile in namespace with a publisher resolves to an endpoint.
+ # This covers deployments where log profile is configured independently of a SecPolicy.
+ try:
+ profiles = k8s.get_resources(cluster_id, "f5biglogprofile", namespace)
+ for profile in profiles:
+ pub_name = profile.get("spec", {}).get("publisher", "")
+ if pub_name:
+ ep = _resolve_hslpub_endpoints(k8s, cluster_id, namespace, pub_name)
+ endpoints.extend(ep)
+ except Exception:
+ logger.debug("Could not scan F5BigLogProfile resources", exc_info=True)
+
+ return list(dict.fromkeys(endpoints)) # deduplicate preserving order
+
+
+def _read_logs_from_clickhouse(
+ ch,
+ cluster_id: int,
+ cr_kind: str,
+ cr_name: str,
+ limit: int,
+ outcome_filter: str | None,
+ attack_type_filter: str | None,
+ vs_name_filter: str | None,
+ ip_filter: str | None = None,
+ uri_filter: str | None = None,
+) -> tuple[list[dict], str | None]:
+ """Query ClickHouse for security logs and return entries in NAP-compatible shape.
+
+ Returns raw_message verbatim so the UI shows the original log format the user
+ configured — whether NAP, custom, or OTEL-normalised.
+ """
+ conditions = ["cluster_id = {cid:UInt32}"]
+ params: dict = {"cid": cluster_id, "limit": limit}
+
+ if cr_kind == "appolicy":
+ conditions.append("policy_name = {policy:String}")
+ params["policy"] = cr_name
+ if outcome_filter:
+ conditions.append("outcome = {outcome:String}")
+ params["outcome"] = outcome_filter.upper()
+ if attack_type_filter:
+ conditions.append("positionCaseInsensitive(attack_type, {atf:String}) > 0")
+ params["atf"] = attack_type_filter
+ if vs_name_filter:
+ conditions.append("positionCaseInsensitive(vs_name, {vsf:String}) > 0")
+ params["vsf"] = vs_name_filter
+ if ip_filter:
+ conditions.append("ip_client = {ipf:String}")
+ params["ipf"] = ip_filter
+ if uri_filter:
+ conditions.append("positionCaseInsensitive(uri, {urif:String}) > 0")
+ params["urif"] = uri_filter
+
+ where = " AND ".join(conditions)
+ # Use string concatenation — where contains ClickHouse {param:Type} placeholders
+ # that Python f-string would try to evaluate, causing a silent ValueError → empty results.
+ # raw_message/ingest_source live in waf_events_otel; waf_events has namespace/ingest_ts.
+ sql = (
+ "SELECT ts, outcome, attack_type, ip_client, method, uri,"
+ " policy_name, vs_name, violation_rating, support_id,"
+ " sig_ids, sig_names,"
+ " '' AS raw_message, 'clickhouse' AS ingest_source"
+ f" FROM {_CLICKHOUSE_DB}.waf_events"
+ f" WHERE {where}"
+ " ORDER BY ts DESC"
+ " LIMIT {limit:UInt32}"
+ )
+
+ try:
+ rows = ch.query(sql, params)
+ entries = []
+ for r in rows:
+ # raw_message = original syslog line as the WAF emitted it
+ # If empty (legacy celery event), synthesise from parsed fields
+ raw = r.get("raw_message") or ""
+ if not raw:
+ raw = (
+ f'attack_type="{r.get("attack_type","")}",'
+ f'date_time="{r.get("ts","")}",ip_client="{r.get("ip_client","")}",method="{r.get("method","")}",policy_name="{r.get("policy_name","")}",outcome="{r.get("outcome","")}",'
+ f'support_id="{r.get("support_id","")}"'
+ )
+ entry = {
+ "raw": raw,
+ "date_time": str(r.get("ts", "")),
+ "outcome": r.get("outcome", ""),
+ "attack_type": r.get("attack_type", ""),
+ "ip_client": r.get("ip_client", ""),
+ "method": r.get("method", ""),
+ "uri": r.get("uri", ""),
+ "policy_name": r.get("policy_name", ""),
+ "vs_name": r.get("vs_name", ""),
+ "violation_rating": str(r.get("violation_rating", "")),
+ "support_id": r.get("support_id", ""),
+ "sig_ids": r.get("sig_ids", ""),
+ "sig_names": r.get("sig_names", ""),
+ "request_status": r.get("outcome", "").lower(),
+ "ingest_source": r.get("ingest_source", ""),
+ }
+ entries.append(entry)
+ return entries, None
+ except Exception as exc:
+ logger.error("ClickHouse security-logs query failed: %s", exc)
+ return [], str(exc)
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/security-logs",
+ dependencies=[Depends(require_viewer)],
+ response_model=WafSecurityLogsResponse,
+)
+@handle_route_errors("fetch WAF security logs")
+def get_waf_security_logs(
+ cluster_id: int,
+ namespace: str,
+ cr_kind: Annotated[str | None, Query(description="'appolicy' or 'f5virtualserver'; omit to query all policies")] = None,
+ cr_name: str | None = None,
+ limit: Annotated[int, Query(ge=1, le=_MAX_LIMIT)] = 200,
+ outcome_filter: str | None = None,
+ attack_type_filter: str | None = None,
+ vs_name_filter: str | None = None,
+ ip_filter: str | None = None,
+ uri_filter: str | None = None,
+ db: Session = Depends(get_db),
+):
+ """
+ Resolve the syslog endpoint for the given CR and return recent security log entries.
+
+ Resolution chain:
+ APPolicy → SecPolicy (items[].kind=F5BigWebSecurityProfile) → F5BigLogProfile → F5BigHslPub
+ F5VirtualServer → SecPolicy (targetRef) → F5BigLogProfile → F5BigHslPub
+ """
+ # When ClickHouse is available, query it directly — no syslog endpoint resolution needed.
+ # raw_message column preserves the original log line in the user's chosen format.
+ ch = get_clickhouse()
+ if ch.available:
+ entries, error = _read_logs_from_clickhouse(
+ ch, cluster_id, cr_kind, cr_name,
+ limit, outcome_filter, attack_type_filter, vs_name_filter,
+ ip_filter, uri_filter,
+ )
+ return {
+ "entries": entries,
+ "total": len(entries),
+ "source_endpoint": "clickhouse",
+ "cr_kind": cr_kind,
+ "cr_name": cr_name,
+ "error": error,
+ "source": "clickhouse",
+ }
+
+ # Fallback path: resolve syslog endpoint and read from pod/TCP
+ k8s = KubernetesService(db)
+ endpoints: list[str] = []
+
+ if cr_kind == "appolicy":
+ endpoints = _resolve_endpoints_for_policy(k8s, cluster_id, namespace, cr_name)
+ elif cr_kind == "f5virtualserver":
+ # F5VirtualServer references a SecPolicy by name in its spec
+ try:
+ vss = k8s.get_resources(cluster_id, "f5virtualserver", namespace)
+ for vs in vss:
+ if vs.get("metadata", {}).get("name") == cr_name:
+ sec_pol_ref = (
+ vs.get("spec", {}).get("securityPolicyRef", {}).get("name")
+ or vs.get("spec", {}).get("secPolicy")
+ or ""
+ )
+ if sec_pol_ref:
+ ep = _resolve_secpolicy_endpoints(k8s, cluster_id, namespace, sec_pol_ref)
+ endpoints.extend(ep)
+ except Exception:
+ logger.debug("Could not resolve F5VirtualServer %s", cr_name, exc_info=True)
+
+ if not endpoints:
+ return {
+ "entries": [],
+ "total": 0,
+ "source_endpoint": None,
+ "cr_kind": cr_kind,
+ "cr_name": cr_name,
+ "warning": (
+ "No syslog endpoint found. Ensure a SecPolicy with a F5BigLogProfile "
+ "and F5BigLogHslpub is attached to this resource."
+ ),
+ }
+
+ # Use the first resolved endpoint (pool members share the same log stream)
+ host_port = endpoints[0]
+ try:
+ host, port_str = host_port.rsplit(":", 1)
+ host = host.strip("[]") # strip IPv6 brackets
+ port = int(port_str)
+ except ValueError:
+ return {
+ "entries": [],
+ "total": 0,
+ "source_endpoint": host_port,
+ "cr_kind": cr_kind,
+ "cr_name": cr_name,
+ "warning": f"Could not parse syslog endpoint address: {host_port!r}",
+ }
+
+ # (ClickHouse already handled above — this is the pure syslog fallback path)
+ entries, error = _read_logs_from_pod(k8s, cluster_id, namespace, limit)
+ if not entries:
+ entries, error = _read_syslog_tcp(host, port, limit)
+
+ if cr_kind == "appolicy":
+ entries = [e for e in entries if not e.get("policy_name") or cr_name in e.get("policy_name", "")]
+ if vs_name_filter:
+ entries = [e for e in entries if vs_name_filter in e.get("vs_name", "")]
+ if outcome_filter:
+ entries = [e for e in entries if e.get("outcome", "").upper() == outcome_filter.upper()]
+ if attack_type_filter:
+ entries = [e for e in entries if attack_type_filter.lower() in e.get("attack_type", "").lower()]
+ if ip_filter:
+ entries = [e for e in entries if e.get("ip_client", "") == ip_filter]
+ if uri_filter:
+ entries = [e for e in entries if uri_filter.lower() in e.get("uri", "").lower()]
+
+ return {
+ "entries": entries,
+ "total": len(entries),
+ "source_endpoint": host_port,
+ "all_endpoints": endpoints,
+ "cr_kind": cr_kind,
+ "cr_name": cr_name,
+ "error": error,
+ "source": "syslog",
+ }
diff --git a/backend/routes/k8s/waf_panels.py b/backend/routes/k8s/waf_panels.py
new file mode 100644
index 00000000..9f14f5c8
--- /dev/null
+++ b/backend/routes/k8s/waf_panels.py
@@ -0,0 +1,204 @@
+"""WAF Panel Builder API — CRUD for per-cluster dashboard panels + data queries.
+
+Each panel stores:
+ - a query_template key (maps to a safe parameterised CH SQL fragment)
+ - chart_type, time_range, width, title, panel_order
+
+GET /api/k8s/clusters/{id}/waf/panels list panels ordered by panel_order
+POST /api/k8s/clusters/{id}/waf/panels create panel
+PUT /api/k8s/clusters/{id}/waf/panels/{panel_id} update panel
+DELETE /api/k8s/clusters/{id}/waf/panels/{panel_id} delete panel
+GET /api/k8s/clusters/{id}/waf/panels/{panel_id}/data execute query → chart data
+GET /api/k8s/clusters/{id}/waf/panels/templates list available query templates
+"""
+from __future__ import annotations
+
+import logging
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel, field_validator
+from sqlalchemy.orm import Session
+
+from core.errors import NotFoundError, handle_route_errors
+from database import get_db
+from models.waf_panels import PANEL_QUERY_TEMPLATES, VALID_CHART_TYPES, VALID_TIME_RANGES, VALID_WIDTHS, WafPanel
+from routes.auth import require_operator, require_viewer
+from services.clickhouse import get_clickhouse
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api")
+
+_RANGE_HOURS = {"1h": 1, "24h": 24, "7d": 168, "30d": 720}
+_BUCKET_HOURS = {"1h": 1, "24h": 1, "7d": 1, "30d": 6}
+
+
+# ── Pydantic schemas ──────────────────────────────────────────────────────────
+
+class PanelCreate(BaseModel):
+ title: str
+ chart_type: str = "bar"
+ query_template: str
+ time_range: str = "7d"
+ width: str = "full"
+ panel_order: int = 0
+ tab_id: int | None = None
+ extra_config: dict | None = None
+
+ @field_validator("chart_type")
+ @classmethod
+ def valid_chart(cls, v: str) -> str:
+ if v not in VALID_CHART_TYPES:
+ raise ValueError(f"chart_type must be one of {VALID_CHART_TYPES}")
+ return v
+
+ @field_validator("query_template")
+ @classmethod
+ def valid_template(cls, v: str) -> str:
+ if v not in PANEL_QUERY_TEMPLATES:
+ raise ValueError(f"query_template must be one of {list(PANEL_QUERY_TEMPLATES)}")
+ return v
+
+ @field_validator("time_range")
+ @classmethod
+ def valid_range(cls, v: str) -> str:
+ if v not in VALID_TIME_RANGES:
+ raise ValueError(f"time_range must be one of {VALID_TIME_RANGES}")
+ return v
+
+ @field_validator("width")
+ @classmethod
+ def valid_width(cls, v: str) -> str:
+ if v not in VALID_WIDTHS:
+ raise ValueError(f"width must be one of {VALID_WIDTHS}")
+ return v
+
+
+class PanelUpdate(BaseModel):
+ title: str | None = None
+ chart_type: str | None = None
+ query_template: str | None = None
+ time_range: str | None = None
+ width: str | None = None
+ panel_order: int | None = None
+ tab_id: int | None = None
+ extra_config: dict | None = None
+
+
+def _panel_to_dict(p: WafPanel) -> dict:
+ return {
+ "id": p.id,
+ "cluster_id": p.cluster_id,
+ "tab_id": p.tab_id,
+ "title": p.title,
+ "chart_type": p.chart_type,
+ "query_template": p.query_template,
+ "time_range": p.time_range,
+ "width": p.width,
+ "panel_order": p.panel_order,
+ "extra_config": p.extra_config,
+ "created_at": p.created_at.isoformat() if p.created_at else None,
+ }
+
+
+# ── Routes ────────────────────────────────────────────────────────────────────
+
+@router.get("/k8s/clusters/{cluster_id}/waf/panels/templates", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list panel templates")
+def list_templates(cluster_id: int):
+ """Return all available query templates with descriptions."""
+ templates = [
+ {"key": k, "description": k.replace("_", " ").title()}
+ for k in PANEL_QUERY_TEMPLATES
+ ]
+ return {"templates": templates, "chart_types": sorted(VALID_CHART_TYPES), "time_ranges": sorted(VALID_TIME_RANGES)}
+
+
+@router.get("/k8s/clusters/{cluster_id}/waf/panels", dependencies=[Depends(require_viewer)])
+@handle_route_errors("list waf panels")
+def list_panels(
+ cluster_id: int,
+ tab_id: Annotated[int | None, Query()] = None,
+ db: Session = Depends(get_db),
+):
+ q = db.query(WafPanel).filter(WafPanel.cluster_id == cluster_id)
+ if tab_id is not None:
+ q = q.filter(WafPanel.tab_id == (tab_id if tab_id != 0 else None))
+ panels = q.order_by(WafPanel.panel_order, WafPanel.id).all()
+ return {"panels": [_panel_to_dict(p) for p in panels]}
+
+
+@router.post("/k8s/clusters/{cluster_id}/waf/panels", dependencies=[Depends(require_operator)])
+@handle_route_errors("create waf panel")
+def create_panel(cluster_id: int, body: PanelCreate, db: Session = Depends(get_db)):
+ data = body.model_dump()
+ if data.get("tab_id") == 0:
+ data["tab_id"] = None
+ panel = WafPanel(cluster_id=cluster_id, **data)
+ db.add(panel)
+ db.commit()
+ db.refresh(panel)
+ return _panel_to_dict(panel)
+
+
+@router.put("/k8s/clusters/{cluster_id}/waf/panels/{panel_id}", dependencies=[Depends(require_operator)])
+@handle_route_errors("update waf panel")
+def update_panel(cluster_id: int, panel_id: int, body: PanelUpdate, db: Session = Depends(get_db)):
+ panel = db.query(WafPanel).filter(WafPanel.id == panel_id, WafPanel.cluster_id == cluster_id).first()
+ if not panel:
+ raise NotFoundError(f"Panel {panel_id} not found")
+ data = body.model_dump(exclude_none=True)
+ if data.get("tab_id") == 0:
+ data["tab_id"] = None
+ for field, value in data.items():
+ setattr(panel, field, value)
+ db.commit()
+ db.refresh(panel)
+ return _panel_to_dict(panel)
+
+
+@router.delete("/k8s/clusters/{cluster_id}/waf/panels/{panel_id}", dependencies=[Depends(require_operator)])
+@handle_route_errors("delete waf panel")
+def delete_panel(cluster_id: int, panel_id: int, db: Session = Depends(get_db)):
+ panel = db.query(WafPanel).filter(WafPanel.id == panel_id, WafPanel.cluster_id == cluster_id).first()
+ if not panel:
+ raise NotFoundError(f"Panel {panel_id} not found")
+ db.delete(panel)
+ db.commit()
+ return {"deleted": panel_id}
+
+
+@router.get("/k8s/clusters/{cluster_id}/waf/panels/{panel_id}/data", dependencies=[Depends(require_viewer)])
+@handle_route_errors("execute panel query")
+def panel_data(
+ cluster_id: int,
+ panel_id: int,
+ time_range: Annotated[str, Query()] = "7d",
+ db: Session = Depends(get_db),
+):
+ """Execute the panel's query template against ClickHouse and return chart data."""
+ panel = db.query(WafPanel).filter(WafPanel.id == panel_id, WafPanel.cluster_id == cluster_id).first()
+ if not panel:
+ raise NotFoundError(f"Panel {panel_id} not found")
+
+ ch = get_clickhouse()
+ if not ch.available:
+ return {"available": False, "reason": "ClickHouse not configured"}
+
+ tr = time_range if time_range in VALID_TIME_RANGES else panel.time_range
+ h = _RANGE_HOURS.get(tr, 168)
+ bucket = _BUCKET_HOURS.get(tr, 1)
+
+ # Safe: query_template is validated against PANEL_QUERY_TEMPLATES at creation
+ sql_template = PANEL_QUERY_TEMPLATES[panel.query_template]
+ sql = sql_template.format(cid=cluster_id, h=h, bucket=bucket)
+
+ rows = ch.query(sql)
+ return {
+ "available": True,
+ "panel_id": panel_id,
+ "chart_type": panel.chart_type,
+ "title": panel.title,
+ "time_range": tr,
+ "rows": rows,
+ }
diff --git a/backend/routes/k8s/waf_policies.py b/backend/routes/k8s/waf_policies.py
new file mode 100644
index 00000000..b47784a5
--- /dev/null
+++ b/backend/routes/k8s/waf_policies.py
@@ -0,0 +1,418 @@
+"""
+WAF Policy Manager routes.
+
+Thin CRUD routes over the existing generic KubernetesService CRD methods for
+appprotect.f5.com/v1 resources (APPolicy, APLogConf, APSignatures, APUserSig),
+which are watched/compiled by the unmodified nap-policy-operator PLM chart
+(Policy Controller + Compiler + SeaweedFS) — no bnk-forge compile logic here.
+
+See docs/WAF_POLICY_MANAGER_DESIGN.md for the full design.
+"""
+
+import logging
+
+import yaml
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+
+from core.errors import NotFoundError, handle_route_errors
+from database import get_db
+from models import User
+from routes.auth import require_cluster_owner, require_viewer
+from schemas.waf import (
+ WafLogConfListResponse,
+ WafOperationResponse,
+ WafPolicyListResponse,
+ WafRecompileResponse,
+ WafResource,
+ WafUserSigListResponse,
+)
+from services.kubernetes_service import KubernetesService
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api", tags=["k8s-waf-policies"])
+
+# Singleton APSignatures resource name (enforced by the CRD's own schema).
+APSIGNATURES_NAME = "apsignatures"
+
+
+# ============================================================================
+# Request models (inline, per AGENTS.md convention)
+# ============================================================================
+
+class WafPolicyCreateRequest(BaseModel):
+ name: str
+ namespace: str
+ spec: dict
+
+
+class WafPolicyUpdateRequest(BaseModel):
+ namespace: str
+ spec: dict
+
+
+class WafLogConfCreateRequest(BaseModel):
+ name: str
+ namespace: str
+ spec: dict
+
+
+class WafUserSigCreateRequest(BaseModel):
+ name: str
+ namespace: str
+ spec: dict
+
+
+class WafSignaturesUpdateRequest(BaseModel):
+ namespace: str
+ spec: dict
+
+
+# ============================================================================
+# Helpers
+# ============================================================================
+
+def _build_resource_yaml(
+ kind: str, name: str, namespace: str, spec: dict, resource_version: str | None = None
+) -> str:
+ """Build a resource dict for appprotect.f5.com/v1 and serialize to YAML.
+
+ Reuses the existing generic create_resource/update_resource methods
+ (which parse resource_yaml), so no changes to services/kubernetes/_resources.py.
+
+ `resource_version` is required for updates (Kubernetes' `replace` semantics use
+ it for optimistic concurrency; omitting it on an update yields a 422 from the
+ API server, confirmed against a live cluster).
+ """
+ metadata: dict = {"name": name, "namespace": namespace}
+ if resource_version:
+ metadata["resourceVersion"] = resource_version
+ resource = {
+ "apiVersion": "appprotect.f5.com/v1",
+ "kind": kind,
+ "metadata": metadata,
+ "spec": spec,
+ }
+ return yaml.dump(resource)
+
+
+def _find_by_name(resources: list[dict], name: str) -> dict | None:
+ for r in resources:
+ if r.get("metadata", {}).get("name") == name:
+ return r
+ return None
+
+
+# ============================================================================
+# APPolicy
+# ============================================================================
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/policies",
+ dependencies=[Depends(require_viewer)],
+ response_model=WafPolicyListResponse,
+)
+@handle_route_errors("list WAF policies")
+def list_waf_policies(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s_service = KubernetesService(db)
+ policies = k8s_service.get_resources(cluster_id, "appolicy", namespace)
+ return {"policies": policies, "count": len(policies)}
+
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/policies/{name}",
+ dependencies=[Depends(require_viewer)],
+ response_model=WafResource,
+)
+@handle_route_errors("get WAF policy")
+def get_waf_policy(cluster_id: int, name: str, namespace: str, db: Session = Depends(get_db)):
+ k8s_service = KubernetesService(db)
+ policies = k8s_service.get_resources(cluster_id, "appolicy", namespace)
+ policy = _find_by_name(policies, name)
+ if not policy:
+ raise NotFoundError("waf_policy", name)
+ return policy
+
+
+@router.post(
+ "/k8s/clusters/{cluster_id}/waf/policies",
+ response_model=WafResource,
+)
+@handle_route_errors("create WAF policy")
+def create_waf_policy(
+ cluster_id: int,
+ request: WafPolicyCreateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ resource_yaml = _build_resource_yaml("APPolicy", request.name, request.namespace, request.spec)
+ result = k8s_service.create_resource(cluster_id, "appolicy", resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.put(
+ "/k8s/clusters/{cluster_id}/waf/policies/{name}",
+ response_model=WafResource,
+)
+@handle_route_errors("update WAF policy")
+def update_waf_policy(
+ cluster_id: int,
+ name: str,
+ request: WafPolicyUpdateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ existing = _find_by_name(k8s_service.get_resources(cluster_id, "appolicy", request.namespace), name)
+ if not existing:
+ raise NotFoundError("waf_policy", name)
+ resource_version = existing.get("metadata", {}).get("resourceVersion")
+ resource_yaml = _build_resource_yaml("APPolicy", name, request.namespace, request.spec, resource_version)
+ result = k8s_service.update_resource(cluster_id, "appolicy", name, resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.delete(
+ "/k8s/clusters/{cluster_id}/waf/policies/{name}",
+ response_model=WafOperationResponse,
+)
+@handle_route_errors("delete WAF policy")
+def delete_waf_policy(
+ cluster_id: int,
+ name: str,
+ namespace: str,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ return k8s_service.delete_resource(cluster_id, "appolicy", name, namespace)
+
+
+# ============================================================================
+# APLogConf
+# ============================================================================
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/logconfs",
+ dependencies=[Depends(require_viewer)],
+ response_model=WafLogConfListResponse,
+)
+@handle_route_errors("list WAF log profiles")
+def list_waf_logconfs(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s_service = KubernetesService(db)
+ log_confs = k8s_service.get_resources(cluster_id, "aplogconf", namespace)
+ return {"log_confs": log_confs, "count": len(log_confs)}
+
+
+@router.post(
+ "/k8s/clusters/{cluster_id}/waf/logconfs",
+ response_model=WafResource,
+)
+@handle_route_errors("create WAF log profile")
+def create_waf_logconf(
+ cluster_id: int,
+ request: WafLogConfCreateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ resource_yaml = _build_resource_yaml("APLogConf", request.name, request.namespace, request.spec)
+ result = k8s_service.create_resource(cluster_id, "aplogconf", resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.put(
+ "/k8s/clusters/{cluster_id}/waf/logconfs/{name}",
+ response_model=WafResource,
+)
+@handle_route_errors("update WAF log profile")
+def update_waf_logconf(
+ cluster_id: int,
+ name: str,
+ request: WafPolicyUpdateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ existing = _find_by_name(k8s_service.get_resources(cluster_id, "aplogconf", request.namespace), name)
+ if not existing:
+ raise NotFoundError("waf_logconf", name)
+ resource_version = existing.get("metadata", {}).get("resourceVersion")
+ resource_yaml = _build_resource_yaml("APLogConf", name, request.namespace, request.spec, resource_version)
+ result = k8s_service.update_resource(cluster_id, "aplogconf", name, resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.delete(
+ "/k8s/clusters/{cluster_id}/waf/logconfs/{name}",
+ response_model=WafOperationResponse,
+)
+@handle_route_errors("delete WAF log profile")
+def delete_waf_logconf(
+ cluster_id: int,
+ name: str,
+ namespace: str,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ return k8s_service.delete_resource(cluster_id, "aplogconf", name, namespace)
+
+
+# ============================================================================
+# APSignatures — singleton per namespace (metadata.name must be "apsignatures")
+# ============================================================================
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/signatures",
+ dependencies=[Depends(require_viewer)],
+ response_model=WafResource | None,
+)
+@handle_route_errors("get WAF signatures")
+def get_waf_signatures(cluster_id: int, namespace: str, db: Session = Depends(get_db)):
+ k8s_service = KubernetesService(db)
+ resources = k8s_service.get_resources(cluster_id, "apsignatures", namespace)
+ return _find_by_name(resources, APSIGNATURES_NAME)
+
+
+@router.put(
+ "/k8s/clusters/{cluster_id}/waf/signatures",
+ response_model=WafResource,
+)
+@handle_route_errors("save WAF signatures")
+def upsert_waf_signatures(
+ cluster_id: int,
+ request: WafSignaturesUpdateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ existing = _find_by_name(
+ k8s_service.get_resources(cluster_id, "apsignatures", request.namespace), APSIGNATURES_NAME
+ )
+ if existing:
+ resource_version = existing.get("metadata", {}).get("resourceVersion")
+ resource_yaml = _build_resource_yaml(
+ "APSignatures", APSIGNATURES_NAME, request.namespace, request.spec, resource_version
+ )
+ result = k8s_service.update_resource(
+ cluster_id, "apsignatures", APSIGNATURES_NAME, resource_yaml, request.namespace
+ )
+ else:
+ resource_yaml = _build_resource_yaml("APSignatures", APSIGNATURES_NAME, request.namespace, request.spec)
+ result = k8s_service.create_resource(cluster_id, "apsignatures", resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.delete(
+ "/k8s/clusters/{cluster_id}/waf/signatures",
+ response_model=WafOperationResponse,
+)
+@handle_route_errors("delete WAF signatures")
+def delete_waf_signatures(
+ cluster_id: int,
+ namespace: str,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ return k8s_service.delete_resource(cluster_id, "apsignatures", APSIGNATURES_NAME, namespace)
+
+
+@router.post(
+ "/k8s/clusters/{cluster_id}/waf/policies/{name}/recompile",
+ response_model=WafRecompileResponse,
+)
+@handle_route_errors("force recompile WAF policy")
+def recompile_waf_policy(
+ cluster_id: int,
+ name: str,
+ namespace: str,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ """Force recompile by bumping a metadata annotation (triggers reconcile loop)."""
+ k8s_service = KubernetesService(db)
+ existing = _find_by_name(k8s_service.get_resources(cluster_id, "appolicy", namespace), name)
+ if not existing:
+ raise NotFoundError("waf_policy", name)
+ resource_version = existing.get("metadata", {}).get("resourceVersion")
+ spec = existing.get("spec", {})
+ # Rebuild with same spec — touch triggers the controller reconcile loop
+ resource_yaml = _build_resource_yaml("APPolicy", name, namespace, spec, resource_version)
+ result = k8s_service.update_resource(cluster_id, "appolicy", name, resource_yaml, namespace)
+ return {"message": f"Recompile triggered for {name}", "resource": result.get("resource", result)}
+
+
+# ============================================================================
+# APUserSig
+# ============================================================================
+
+@router.get(
+ "/k8s/clusters/{cluster_id}/waf/usersigs",
+ dependencies=[Depends(require_viewer)],
+ response_model=WafUserSigListResponse,
+)
+@handle_route_errors("list WAF user signatures")
+def list_waf_usersigs(cluster_id: int, namespace: str | None = None, db: Session = Depends(get_db)):
+ k8s_service = KubernetesService(db)
+ user_sigs = k8s_service.get_resources(cluster_id, "apusersig", namespace)
+ return {"user_sigs": user_sigs, "count": len(user_sigs)}
+
+
+@router.post(
+ "/k8s/clusters/{cluster_id}/waf/usersigs",
+ response_model=WafResource,
+)
+@handle_route_errors("create WAF user signature")
+def create_waf_usersig(
+ cluster_id: int,
+ request: WafUserSigCreateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ resource_yaml = _build_resource_yaml("APUserSig", request.name, request.namespace, request.spec)
+ result = k8s_service.create_resource(cluster_id, "apusersig", resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.put(
+ "/k8s/clusters/{cluster_id}/waf/usersigs/{name}",
+ response_model=WafResource,
+)
+@handle_route_errors("update WAF user signature")
+def update_waf_usersig(
+ cluster_id: int,
+ name: str,
+ request: WafPolicyUpdateRequest,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ existing = _find_by_name(k8s_service.get_resources(cluster_id, "apusersig", request.namespace), name)
+ if not existing:
+ raise NotFoundError("waf_usersig", name)
+ resource_version = existing.get("metadata", {}).get("resourceVersion")
+ resource_yaml = _build_resource_yaml("APUserSig", name, request.namespace, request.spec, resource_version)
+ result = k8s_service.update_resource(cluster_id, "apusersig", name, resource_yaml, request.namespace)
+ return result.get("resource", result)
+
+
+@router.delete(
+ "/k8s/clusters/{cluster_id}/waf/usersigs/{name}",
+ response_model=WafOperationResponse,
+)
+@handle_route_errors("delete WAF user signature")
+def delete_waf_usersig(
+ cluster_id: int,
+ name: str,
+ namespace: str,
+ user: User = Depends(require_cluster_owner),
+ db: Session = Depends(get_db),
+):
+ k8s_service = KubernetesService(db)
+ return k8s_service.delete_resource(cluster_id, "apusersig", name, namespace)
diff --git a/backend/schemas/waf.py b/backend/schemas/waf.py
new file mode 100644
index 00000000..77ef9b48
--- /dev/null
+++ b/backend/schemas/waf.py
@@ -0,0 +1,85 @@
+"""
+Pydantic response models for the WAF Policy Manager routes.
+
+These mirror the *actual* return shapes of routes/k8s/waf_policies.py and
+routes/k8s/waf_logs.py so `response_model=` can type the generated OpenAPI
+contract (and the frontend types derived from it) per the AGENTS.md convention.
+
+The individual appprotect.f5.com/v1 custom resources (APPolicy, APLogConf,
+APSignatures, APUserSig) are returned verbatim from the Kubernetes API, so
+``WafResource`` allows extra keys rather than pinning a partial CRD schema.
+"""
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class WafResource(BaseModel):
+ """A single appprotect.f5.com/v1 custom resource, returned verbatim from the cluster.
+
+ Extra keys are allowed so the raw Kubernetes object (arbitrary CRD spec/status
+ fields, managedFields, etc.) passes through the response model unchanged.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ apiVersion: str | None = None
+ kind: str | None = None
+ metadata: dict[str, Any] = Field(default_factory=dict)
+ spec: dict[str, Any] = Field(default_factory=dict)
+ status: dict[str, Any] | None = None
+
+
+class WafPolicyListResponse(BaseModel):
+ """GET /waf/policies — APPolicy list envelope."""
+
+ policies: list[dict[str, Any]]
+ count: int
+
+
+class WafLogConfListResponse(BaseModel):
+ """GET /waf/logconfs — APLogConf list envelope."""
+
+ log_confs: list[dict[str, Any]]
+ count: int
+
+
+class WafUserSigListResponse(BaseModel):
+ """GET /waf/usersigs — APUserSig list envelope."""
+
+ user_sigs: list[dict[str, Any]]
+ count: int
+
+
+class WafOperationResponse(BaseModel):
+ """Delete responses from KubernetesService.delete_resource."""
+
+ success: bool = True
+ message: str
+
+
+class WafRecompileResponse(BaseModel):
+ """POST /waf/policies/{name}/recompile."""
+
+ message: str
+ resource: dict[str, Any] = Field(default_factory=dict)
+
+
+class WafSecurityLogsResponse(BaseModel):
+ """GET /waf/security-logs — security log entries plus resolution metadata.
+
+ Covers all return branches (ClickHouse, resolved syslog endpoint, and the
+ no-endpoint / unparseable-endpoint fallbacks), so most metadata fields are
+ optional.
+ """
+
+ entries: list[dict[str, Any]]
+ total: int
+ source_endpoint: str | None = None
+ cr_kind: str | None = None
+ cr_name: str | None = None
+ all_endpoints: list[str] | None = None
+ source: str | None = None
+ error: str | None = None
+ warning: str | None = None
diff --git a/backend/services/clickhouse.py b/backend/services/clickhouse.py
new file mode 100644
index 00000000..58bedf94
--- /dev/null
+++ b/backend/services/clickhouse.py
@@ -0,0 +1,178 @@
+"""
+ClickHouse client service for WAF dashboard analytics.
+
+Connects to ClickHouse via its HTTP interface (port 8123) using clickhouse-connect.
+Gracefully degrades when CLICKHOUSE_URL is not configured — callers receive
+None from get_client() and should return a suitable "not configured" response.
+
+Schema (created on first connect if missing):
+ waf_events — MergeTree, partitioned by month, ordered by (cluster_id, ts)
+"""
+from __future__ import annotations
+
+import logging
+import os
+from contextlib import suppress
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+# ClickHouse HTTP endpoint — overridable via env var.
+# Default assumes ClickHouse runs on the same host (network_mode: host or localhost).
+_CLICKHOUSE_URL = os.getenv("CLICKHOUSE_URL", "")
+_CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
+_CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
+_CLICKHOUSE_DB = os.getenv("CLICKHOUSE_DB", "bnkforge")
+
+# Exposed for use in route modules so the DB name stays env-configurable
+CLICKHOUSE_DB = _CLICKHOUSE_DB
+
+# DDL executed once to set up the database and table
+_DDL_STATEMENTS = [
+ f"CREATE DATABASE IF NOT EXISTS {_CLICKHOUSE_DB}",
+ f"""
+ CREATE TABLE IF NOT EXISTS {_CLICKHOUSE_DB}.waf_events (
+ cluster_id UInt32,
+ ts DateTime,
+ policy_name LowCardinality(String),
+ vs_name LowCardinality(String),
+ outcome LowCardinality(String),
+ attack_type LowCardinality(String),
+ ip_client String,
+ uri String,
+ method LowCardinality(String),
+ violation_rating UInt8,
+ support_id String,
+ sig_ids String,
+ sig_names String,
+ namespace LowCardinality(String),
+ ingest_ts DateTime DEFAULT now()
+ )
+ ENGINE = MergeTree()
+ PARTITION BY toYYYYMM(ts)
+ ORDER BY (cluster_id, ts)
+ TTL ts + INTERVAL 90 DAY
+ SETTINGS index_granularity = 8192
+ """,
+]
+
+
+class ClickHouseService:
+ """Thin wrapper around clickhouse-connect for WAF analytics queries.
+
+ Uses a new HTTP client per query — clickhouse_connect's HTTP transport is
+ stateless, so per-request clients are safe and avoid shared-state race
+ conditions when FastAPI handles multiple concurrent requests.
+ """
+
+ def __init__(self) -> None:
+ self._available = False
+ self._host: str = ""
+ self._port: int = 8123
+ self._url = _CLICKHOUSE_URL
+ if self._url:
+ self._connect()
+
+ def _connect(self) -> None:
+ try:
+ import clickhouse_connect # type: ignore[import-untyped]
+
+ url = self._url.rstrip("/")
+ if "://" in url:
+ url = url.split("://", 1)[1]
+ host, _, port_str = url.partition(":")
+ self._host = host
+ self._port = int(port_str) if port_str else 8123
+
+ # Create a temporary client just to verify connectivity and set up schema
+ client = clickhouse_connect.get_client(
+ host=self._host,
+ port=self._port,
+ username=_CLICKHOUSE_USER,
+ password=_CLICKHOUSE_PASSWORD,
+ connect_timeout=5,
+ send_receive_timeout=30,
+ )
+ for ddl in _DDL_STATEMENTS:
+ client.command(ddl)
+ client.close()
+ self._available = True
+ logger.info("ClickHouse ready: %s:%s", self._host, self._port)
+ except ImportError:
+ logger.warning("clickhouse-connect not installed — WAF dashboard disabled")
+ except Exception as exc:
+ logger.warning("ClickHouse connection failed: %s", exc)
+
+ def _new_client(self) -> Any:
+ """Create a fresh HTTP client for a single query — avoids shared-state races."""
+ import clickhouse_connect # type: ignore[import-untyped]
+ return clickhouse_connect.get_client(
+ host=self._host,
+ port=self._port,
+ username=_CLICKHOUSE_USER,
+ password=_CLICKHOUSE_PASSWORD,
+ connect_timeout=5,
+ send_receive_timeout=30,
+ )
+
+ @property
+ def available(self) -> bool:
+ return self._available
+
+ def query(self, sql: str, parameters: dict | None = None) -> list[dict]:
+ """Execute a SELECT using a fresh per-request client to avoid concurrency issues."""
+ if not self._available:
+ return []
+ client = None
+ try:
+ client = self._new_client()
+ result = client.query(sql, parameters=parameters or {})
+ cols = result.column_names
+ return [dict(zip(cols, row)) for row in result.result_rows]
+ except Exception as exc:
+ logger.error("ClickHouse query failed: %s | SQL: %.200s", exc, sql)
+ return []
+ finally:
+ if client:
+ with suppress(Exception):
+ client.close()
+
+ def insert_rows(self, rows: list[dict]) -> int:
+ """Bulk-insert rows into waf_events using a fresh client."""
+ if not self._available or not rows:
+ return 0
+ client = None
+ try:
+ client = self._new_client()
+ cols = list(rows[0].keys())
+ data = [[r[c] for c in cols] for r in rows]
+ client.insert(f"{_CLICKHOUSE_DB}.waf_events", data, column_names=cols)
+ return len(rows)
+ except Exception as exc:
+ logger.error("ClickHouse insert failed: %s", exc)
+ return 0
+ finally:
+ if client:
+ with suppress(Exception):
+ client.close()
+
+ def count_events(self, cluster_id: int, hours: int = 24) -> int:
+ rows = self.query(
+ f"SELECT count() FROM {_CLICKHOUSE_DB}.waf_events"
+ " WHERE cluster_id = {cid:UInt32} AND ts >= now() - INTERVAL {h:UInt32} HOUR",
+ {"cid": cluster_id, "h": hours},
+ )
+ return int(rows[0].get("count()", 0)) if rows else 0
+
+
+# Module-level singleton — created once on import.
+# Returns None-equivalent when CLICKHOUSE_URL is not set.
+_service: ClickHouseService | None = None
+
+
+def get_clickhouse() -> ClickHouseService:
+ """Return the module-level ClickHouseService singleton."""
+ global _service # noqa: PLW0603
+ if _service is None:
+ _service = ClickHouseService()
+ return _service
diff --git a/backend/tests/unit/test_waf_logs.py b/backend/tests/unit/test_waf_logs.py
new file mode 100644
index 00000000..f19e6afb
--- /dev/null
+++ b/backend/tests/unit/test_waf_logs.py
@@ -0,0 +1,264 @@
+"""
+Unit tests for WAF security log route helpers (routes/k8s/waf_logs.py).
+All K8s and socket I/O is mocked — no cluster connection required.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from routes.k8s.waf_logs import (
+ _parse_nap_entry,
+ _read_syslog_tcp,
+ _resolve_endpoints_for_policy,
+ _resolve_hslpub_endpoints,
+ _resolve_logprofile_endpoints,
+ _resolve_secpolicy_endpoints,
+)
+
+# ── _parse_nap_entry ───────────────────────────────────────────────────────
+
+class TestParseNapEntry:
+ def test_parses_key_value_pairs(self):
+ line = 'outcome="BLOCKED" attack_type="SQL Injection" client_ip="1.2.3.4"'
+ entry = _parse_nap_entry(line)
+ assert entry["outcome"] == "BLOCKED"
+ assert entry["attack_type"] == "SQL Injection"
+ assert entry["client_ip"] == "1.2.3.4"
+ assert entry["raw"] == line
+
+ def test_raw_always_present(self):
+ entry = _parse_nap_entry("no kv pairs here")
+ assert entry["raw"] == "no kv pairs here"
+
+ def test_empty_values_parsed(self):
+ line = 'sig_ids="" support_id="abc123"'
+ entry = _parse_nap_entry(line)
+ assert entry["sig_ids"] == ""
+ assert entry["support_id"] == "abc123"
+
+ def test_strips_trailing_whitespace(self):
+ entry = _parse_nap_entry(" outcome=\"PASSED\" ")
+ assert entry["raw"] == "outcome=\"PASSED\""
+
+ def test_full_nap_log_line(self):
+ line = (
+ 'date_time="2026-08-18 10:00:00" unit_hostname="nginx-pod-abc" '
+ 'policy_name="my-waf-policy" vs_name="vs-http" outcome="BLOCKED" '
+ 'violation_rating="5" attack_type="XSS" method="GET" '
+ 'uri="/search?q=
"),
+ ("XSS", "GET", "/?q=
"),
+ ("XSS", "GET", "/?redirect=javascript:alert(1)"),
+ ("XSS", "POST", "/comment"),
+ ("XSS", "GET", "/?name=