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="), + ("Path Traversal", "GET", "/?file=../../../../etc/passwd"), + ("Path Traversal", "GET", "/download?name=../../../etc/shadow"), + ("Path Traversal", "GET", "/?path=..%2F..%2F..%2Fetc%2Fpasswd"), + ("Command Injection", "GET", "/?cmd=;cat+/etc/passwd"), + ("Command Injection", "GET", "/api/ping?host=localhost;id"), + ("Command Injection", "GET", "/?exec=ls+%2Fvar"), + ("Remote Code Execution", "GET", "/?url=http://169.254.169.254/latest/meta-data/"), + ("Remote Code Execution", "GET", "/api/run?code=__import__%28os%29.system%28id%29"), + ("Remote Code Execution", "POST", "/eval"), + ("SSRF", "GET", "/?url=http://internal-api:8080/secrets"), + ("SSRF", "GET", "/?redirect=http://10.0.0.1/admin"), + ("SSRF", "GET", "/proxy?target=file:///etc/passwd"), + ("Scanning", "GET", "/.env"), + ("Scanning", "GET", "/wp-admin/"), + ("Scanning", "GET", "/phpmyadmin/"), + ("Scanning", "GET", "/.git/config"), + ("Scanning", "GET", "/admin"), + ("Scanning", "GET", "/config.php"), + ("Scanning", "GET", "/backup.sql"), + ("Method Abuse", "DELETE", "/api/users/1"), + ("Method Abuse", "TRACE", "/"), + # From the provided test script — covering more attack categories + ("LFI", "GET", "/?page=../../../../etc/passwd"), + ("LFI", "GET", "/?file=../../../etc/shadow"), + ("XSS", "POST", "/comment"), # JSON body with XSS + ("XSS", "GET", "/?param=%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E"), + ("Command Injection", "POST", "/api/ping"), # cmd=ls | cat /etc/passwd + ("Information Leakage", "GET", "/.git/config"), + ("Information Leakage", "GET", "/api/run"), + ("Scanning", "GET", "/wp-login.php"), + ("Scanning", "GET", "/.well-known/security.txt"), +] + +USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/119.0.0.0", + "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15", + "curl/7.88.1", "python-requests/2.31.0", "Go-http-client/1.1", + "sqlmap/1.7.8#stable", "Nikto/2.1.6", "masscan/1.3", + "Mozilla/5.0 (compatible; Googlebot/2.1)", "Mozilla/5.0 (compatible; bingbot/2.0)", + "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", + "Mozilla/5.0 (Android 13; Mobile; rv:109.0) Gecko/114.0 Firefox/114.0", +] + +# 70% chance of picking from a fixed pool so repeat attacker IPs accumulate hits +_ATTACKER_POOL = [ + "185.234.218.42", "45.142.212.115", "91.108.56.182", "5.188.86.172", + "31.184.196.88", "198.235.24.155", "212.102.34.87", "162.55.243.171", + "195.123.240.29", "89.248.165.142", "103.21.76.33", "104.16.231.226", + "172.67.104.58", "185.234.7.115", "45.142.94.121", "91.108.204.221", + "31.184.105.44", "198.235.141.125", "212.102.6.97", "162.55.230.206", + "45.142.48.122", "195.123.125.190", "185.234.186.17", "104.16.42.149", + "172.67.8.200", "89.248.36.110", "91.108.110.28", "31.184.226.1", + "5.188.32.213", "104.16.77.98", +] + +def _random_ip() -> str: + if random.randint(1, 10) <= 7: + return random.choice(_ATTACKER_POOL) + prefixes = ["185.234.", "45.142.", "91.108.", "5.188.", "31.184.", "198.235."] + return random.choice(prefixes) + str(random.randint(1, 254)) + "." + str(random.randint(1, 254)) + +def _legit_ip() -> str: + prefixes = ["98.234.", "76.120.", "71.198.", "108.14.", "67.189.", "50.77."] + return random.choice(prefixes) + str(random.randint(1, 254)) + "." + str(random.randint(1, 254)) + + +def _parse_target(target: str): + parsed = urllib.parse.urlparse(target) + return parsed.hostname, parsed.port or 80 + + +def _current_rps() -> float: + """Modulate RPS sinusoidally over a 10-min period between 0.5x and 3x base.""" + period = 600 # 10-minute oscillation + phase = (time.time() % period) / period * 2 * math.pi + # Add a second faster 3-min oscillation for more realistic spikiness + fast_phase = (time.time() % 180) / 180 * 2 * math.pi + factor = 1.75 + 1.25 * math.sin(phase) + 0.5 * math.sin(fast_phase) + return max(0.5, RPS_BASE * factor) + + +def send_request(target: str, method: str, path: str, is_attack: bool, + attack_type: str = "", ua_override=None) -> int: + """Send raw HTTP request so attack chars are NOT percent-encoded by Python.""" + ua = ua_override or random.choice(USER_AGENTS) + src_ip = _random_ip() if is_attack else _legit_ip() + + body_bytes = None + content_type = "application/x-www-form-urlencoded" + if method in ("POST", "PUT"): + if "login" in path: + raw = "username=admin&password=' OR '1'='1" if is_attack else "username=testuser&password=securepass" + elif "comment" in path: + raw = '{"username": "admin", "data": ""}' + content_type = "application/json" + elif "eval" in path: + raw = "" if is_attack else "Great product!" + elif "upload" in path: + # Large payload — triggers alarm-only if under block threshold + raw = "A" * 8000 + elif "ping" in path: + raw = "cmd=ls | cat /etc/passwd" + else: + raw = "{}" + body_bytes = raw.encode() + + label = f"ATTACK({attack_type})" if is_attack else "LEGIT" + host, port = _parse_target(target) + + try: + conn = http.client.HTTPConnection(host, port, timeout=5) + headers = { + "User-Agent": ua, + "X-Forwarded-For": src_ip, + "Accept": "text/html,application/json,*/*", + "Host": f"{host}:{port}", + } + if body_bytes: + headers["Content-Type"] = content_type + headers["Content-Length"] = str(len(body_bytes)) + conn.request(method, path, body=body_bytes, headers=headers) + resp = conn.getresponse() + code = resp.status + resp.read() + conn.close() + except (http.client.HTTPException, socket.timeout, OSError, ConnectionResetError) as e: + log.warning("request failed: %s %s%s: %s", method, target, path, e) + return 0 + + log.info("%s %s %s%s → HTTP %d [src=%s]", label, method, target, path, code, src_ip) + return code + + +def main(): + log.info("WAF traffic generator starting — modulated RPS, ALERTED traffic enabled") + log.info("Targets: %s", TARGETS) + log.info("Base RPS: %.1f Attack ratio: %d%%", RPS_BASE, ATTACK_RATIO) + + stats = {"legit": 0, "attack": 0, "alarm": 0} + last_report = time.time() + + while True: + rps = _current_rps() + sleep_between = 1.0 / rps + + target = random.choice(TARGETS) + + # Decide traffic category: + # 55% attack (REJECTED), 10% alarm-only bot (ALERTED), 35% legit (PASSED) + roll = random.randint(1, 100) + if roll <= ATTACK_RATIO: + # Blocked attack + attack_type, method, path = random.choice(ATTACK_PAYLOADS) + send_request(target, method, path, is_attack=True, attack_type=attack_type) + stats["attack"] += 1 + elif roll <= ATTACK_RATIO + 10: + # Alarm-only bot traffic → produces ALERTED events + attack_type, method, path, bot_ua = random.choice(ALARM_ONLY_PAYLOADS) + ua = bot_ua or random.choice(BOT_USER_AGENTS) + send_request(target, method, path, is_attack=False, attack_type=attack_type, ua_override=ua) + stats["alarm"] += 1 + else: + path = random.choice(LEGIT_PATHS) + method = random.choice(LEGIT_METHODS) + send_request(target, method, path, is_attack=False) + stats["legit"] += 1 + + if time.time() - last_report >= 60: + total = sum(stats.values()) + log.info( + "=== 1-min: total=%d legit=%d attack=%d alarm-bot=%d rps=%.1f ===", + total, stats["legit"], stats["attack"], stats["alarm"], rps, + ) + stats = {"legit": 0, "attack": 0, "alarm": 0} + last_report = time.time() + + time.sleep(sleep_between * random.uniform(0.5, 1.5)) + + +if __name__ == "__main__": + main() diff --git a/k8s/waf-config.yaml b/k8s/waf-config.yaml new file mode 100644 index 00000000..93a7dfde --- /dev/null +++ b/k8s/waf-config.yaml @@ -0,0 +1,155 @@ +--- +# NAP WAF policy - blocks common injection attacks +apiVersion: appprotect.f5.com/v1beta1 +kind: APPolicy +metadata: + name: waf-policy + namespace: default +spec: + policy: + name: waf-policy + template: + name: POLICY_TEMPLATE_NGINX_BASE + applicationLanguage: utf-8 + enforcementMode: blocking + signature-sets: + - name: All Signatures + alarm: true + block: true + blocking-settings: + violations: + - name: VIOL_RATING_THREAT + alarm: true + block: true + - name: VIOL_RATING_NEED_EXAMINATION + alarm: true + block: true + - name: VIOL_PARAMETER_VALUE_METACHAR + alarm: true + block: true + - name: VIOL_EVASION + alarm: true + block: true + - name: VIOL_ENCODING + alarm: true + block: true + server-technologies: + - serverTechnologyName: Python + - serverTechnologyName: Nginx +--- +# Log everything - illegal requests only, default format +apiVersion: appprotect.f5.com/v1beta1 +kind: APLogConf +metadata: + name: waf-logconf + namespace: default +spec: + content: + format: default + max_request_size: any + max_message_size: 64k + filter: + request_type: illegal +--- +# HSL publisher pointing to OTel TCP syslog receiver +apiVersion: k8s.f5net.com/v1 +kind: F5BigLogHslpub +metadata: + name: waf-publisher + namespace: default +spec: + pool: + - name: waf-syslog + endpoint: + - 10.101.0.3:30841 + - 10.101.0.2:30841 +--- +# Log profile referencing the HSL publisher; nat.enabled creates security_log_profile in TMM +apiVersion: k8s.f5net.com/v1 +kind: F5BigLogProfile +metadata: + name: waf-log-profile + namespace: default +spec: + publisher: waf-publisher + nat: + enabled: true +--- +# Web security profile - links APPolicy to TMM +apiVersion: k8s.f5net.com/v1 +kind: F5BigWebSecurityProfile +metadata: + name: waf-web-security + namespace: default +spec: + policyName: waf-policy +--- +# Backend service for waf-server pod +apiVersion: v1 +kind: Service +metadata: + name: waf-server-svc + namespace: default +spec: + selector: + app: waf-server + ports: + - port: 9080 + targetPort: 9080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: f5-gatewayclass +spec: + controllerName: "f5.com/default-f5-cne-controller" + description: "F5 BIG-IP Kubernetes Gateway" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: waf-gateway + namespace: default +spec: + gatewayClassName: f5-gatewayclass + addresses: + - type: IPAddress + value: 11.11.11.201 + listeners: + - name: http + protocol: HTTP + port: 9080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: waf-route + namespace: default +spec: + parentRefs: + - name: waf-gateway + sectionName: http + rules: + - backendRefs: + - name: waf-server-svc + port: 9080 +--- +# Attach WAF policy + log profile to the Gateway (Gateway API direct policy attachment) +apiVersion: gateway.k8s.f5.com/v1alpha1 +kind: SecPolicy +metadata: + name: waf-secpolicy + namespace: default +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: waf-gateway + extensionRefs: + - group: k8s.f5net.com + kind: F5BigWebSecurityProfile + name: waf-web-security + - group: k8s.f5net.com + kind: F5BigLogProfile + name: waf-log-profile + diff --git a/k8s/waf-otel/otel-collector.yaml b/k8s/waf-otel/otel-collector.yaml new file mode 100644 index 00000000..01d1b8e6 --- /dev/null +++ b/k8s/waf-otel/otel-collector.yaml @@ -0,0 +1,209 @@ +--- +# Namespace for all WAF observability components +apiVersion: v1 +kind: Namespace +metadata: + name: waf-otel +--- +# ConfigMap: OTEL Collector pipeline config +# Receives TCP syslog from TMM HSL publisher, transforms NAP key=value → canonical +# OTEL attributes, and exports to ClickHouse. +apiVersion: v1 +kind: ConfigMap +metadata: + name: otel-collector-config + namespace: waf-otel +data: + config.yaml: | + receivers: + # RFC3164 syslog receiver — HSL publisher wraps NAP events in syslog framing + syslog: + tcp: + listen_address: "0.0.0.0:5514" + protocol: rfc3164 + + extensions: + health_check: + endpoint: "0.0.0.0:13133" + + processors: + # Tag every record with the BNK-Forge cluster_id and ingest source + resource/cluster: + attributes: + - key: cluster_id + value: "1" + action: insert + + # Parse NAP key=value fields from the syslog body + transform/nap_parse: + log_statements: + - context: log + statements: + - set(attributes["raw_message"], body) + - set(attributes["cluster_id"], resource.attributes["cluster_id"]) + - set(attributes["attack_type"], + ExtractPatterns(body, "attack_type=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "attack_type=\"") + - set(attributes["outcome"], + ExtractPatterns(body, "outcome=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "outcome=\"") + - set(attributes["outcome"], + ExtractPatterns(body, "request_status=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "request_status=\"") and attributes["outcome"] == "" + - set(attributes["policy_name"], + ExtractPatterns(body, "policy_name=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "policy_name=\"") + - set(attributes["vs_name"], + ExtractPatterns(body, "vs_name=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "vs_name=\"") + - set(attributes["ip_client"], + ExtractPatterns(body, "ip_client=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "ip_client=\"") + - set(attributes["uri"], + ExtractPatterns(body, "uri=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "uri=\"") + - set(attributes["method"], + ExtractPatterns(body, "method=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "method=\"") + - set(attributes["support_id"], + ExtractPatterns(body, "support_id=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "support_id=\"") + - set(attributes["sig_ids"], + ExtractPatterns(body, "sig_ids=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "sig_ids=\"") + - set(attributes["sig_names"], + ExtractPatterns(body, "sig_names=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "sig_names=\"") + - set(attributes["violation_rating"], + ExtractPatterns(body, "violation_rating=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "violation_rating=\"") + - set(attributes["date_time"], + ExtractPatterns(body, "date_time=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "date_time=\"") + - set(attributes["namespace"], + ExtractPatterns(body, "namespace=\"(?P[^\"]*)\"")["v"]) + where IsMatch(body, "namespace=\"") + - set(attributes["ingest_source"], "otel") + + # Drop non-NAP lines — anything without a policy_name is not a WAF security event + filter/waf_only: + logs: + log_record: + - 'attributes["policy_name"] == ""' + + batch: + send_batch_size: 200 + timeout: 5s + + exporters: + # ClickHouse native protocol — writes to waf_events_otel; MV transforms → waf_events + clickhouse: + endpoint: tcp://10.96.183.55:9000 + username: bnkforge + password: bnkforge_ch_pass + database: bnkforge + logs_table_name: waf_events_otel + create_schema: false + timeout: 10s + retry_on_failure: + enabled: true + initial_interval: 5s + max_elapsed_time: 300s + + debug: + verbosity: basic + sampling_initial: 5 + sampling_thereafter: 200 + + service: + extensions: [health_check] + pipelines: + logs: + receivers: [syslog] + processors: [resource/cluster, transform/nap_parse, filter/waf_only, batch] + exporters: [clickhouse, debug] +--- +# Service: exposes TCP syslog port inside the cluster +# TMM's F5BigLogHslpub will point to this service IP:port +apiVersion: v1 +kind: Service +metadata: + name: otel-collector + namespace: waf-otel + labels: + app: otel-collector +spec: + selector: + app: otel-collector + ports: + - name: syslog-tcp + port: 5514 + targetPort: 5514 + protocol: TCP + type: ClusterIP +--- +# NodePort service so TMM (which uses node IPs in HSL config) can reach the collector +apiVersion: v1 +kind: Service +metadata: + name: otel-collector-nodeport + namespace: waf-otel + labels: + app: otel-collector +spec: + selector: + app: otel-collector + ports: + - name: syslog-tcp + port: 5514 + targetPort: 5514 + nodePort: 30841 + protocol: TCP + type: NodePort +--- +# Deployment: single OTEL Collector instance +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector + namespace: waf-otel + labels: + app: otel-collector +spec: + replicas: 1 + selector: + matchLabels: + app: otel-collector + template: + metadata: + labels: + app: otel-collector + spec: + containers: + - name: otel-collector + image: otel/opentelemetry-collector-contrib:0.107.0 + args: ["--config=/conf/config.yaml"] + ports: + - name: syslog-tcp + containerPort: 5514 + protocol: TCP + volumeMounts: + - name: config + mountPath: /conf + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + readinessProbe: + httpGet: + path: / + port: 13133 # OTEL health check extension port + initialDelaySeconds: 10 + periodSeconds: 10 + volumes: + - name: config + configMap: + name: otel-collector-config diff --git a/k8s/waf-syslog-receiver.yaml b/k8s/waf-syslog-receiver.yaml new file mode 100644 index 00000000..82c3423d --- /dev/null +++ b/k8s/waf-syslog-receiver.yaml @@ -0,0 +1,67 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: waf-syslog-receiver + namespace: default +spec: + replicas: 1 + selector: + matchLabels: + app: waf-syslog-receiver + template: + metadata: + labels: + app: waf-syslog-receiver + spec: + containers: + - name: syslog + image: artifactory.f5net.com/f5-positron-docker/testing-utils:v0.6.0 + command: ["/bin/bash", "-c"] + args: + - | + python3 - <<'EOF' + import socket, datetime, threading, time + + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("0.0.0.0", 5514)) + srv.listen(16) + print("WAF syslog receiver listening on :5514", flush=True) + + def handle(conn, addr): + print(f"[CONNECT] {addr}", flush=True) + conn.settimeout(2.0) + while True: + try: + data = conn.recv(65536) + if not data: + break + ts = datetime.datetime.utcnow().isoformat() + # Print both text (decoded) and hex for binary analysis + text = data.decode(errors='replace') + hexdump = data[:64].hex() + print(f"[{ts}][{len(data)}b] text={text[:200]!r} hex={hexdump}", flush=True) + except socket.timeout: + pass + print(f"[DISCONNECT] {addr}", flush=True) + conn.close() + + while True: + conn, addr = srv.accept() + threading.Thread(target=handle, args=(conn, addr), daemon=True).start() + EOF + ports: + - containerPort: 5514 +--- +apiVersion: v1 +kind: Service +metadata: + name: waf-syslog-receiver + namespace: default +spec: + selector: + app: waf-syslog-receiver + ports: + - port: 5514 + targetPort: 5514