Netdata.cloud bot for Zulip
1"""FastAPI webhook server for receiving Netdata notifications."""
2
3import ssl
4from pathlib import Path
5from typing import Dict, Any
6
7import structlog
8import uvicorn
9from fastapi import FastAPI, HTTPException, Request, status
10from fastapi.responses import JSONResponse
11
12from .formatter import ZulipMessageFormatter
13from .models import WebhookPayload, ZulipConfig, ServerConfig
14from .zulip_client import ZulipNotifier
15
16logger = structlog.get_logger()
17
18
19class NetdataWebhookServer:
20 """FastAPI server for handling Netdata Cloud webhooks."""
21
22 def __init__(self, zulip_config: ZulipConfig, server_config: ServerConfig):
23 """Initialize the webhook server."""
24 self.app = FastAPI(
25 title="Netdata Zulip Bot",
26 description="Webhook service for Netdata Cloud notifications",
27 version="0.1.0"
28 )
29 self.zulip_config = zulip_config
30 self.server_config = server_config
31 self.formatter = ZulipMessageFormatter()
32
33 # Initialize Zulip client
34 try:
35 self.zulip_notifier = ZulipNotifier(zulip_config)
36 except Exception as e:
37 logger.error("Failed to initialize Zulip client", error=str(e))
38 raise
39
40 self._setup_routes()
41 self._setup_middleware()
42
43 def _setup_routes(self):
44 """Setup FastAPI routes."""
45
46 @self.app.get("/health")
47 async def health_check():
48 """Health check endpoint."""
49 return {"status": "healthy", "service": "netdata-zulip-bot"}
50
51 @self.app.post("/webhook/netdata")
52 async def netdata_webhook(request: Request):
53 """Handle Netdata Cloud webhook notifications."""
54 try:
55 # Get raw JSON data
56 body = await request.json()
57 logger.info("Received webhook", payload_keys=list(body.keys()))
58
59 # Parse and validate the payload
60 notification = WebhookPayload.parse(body)
61 logger.info("Parsed notification", type=type(notification).__name__)
62
63 # Format message for Zulip
64 topic, content = self.formatter.format_notification(notification)
65 logger.info("Formatted message", topic=topic)
66
67 # Send to Zulip
68 success = self.zulip_notifier.send_message(topic, content)
69
70 if success:
71 return {"status": "success", "message": "Notification sent to Zulip"}
72 else:
73 raise HTTPException(
74 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
75 detail="Failed to send notification to Zulip"
76 )
77
78 except ValueError as e:
79 logger.error("Invalid webhook payload", error=str(e))
80 raise HTTPException(
81 status_code=status.HTTP_400_BAD_REQUEST,
82 detail=f"Invalid payload format: {str(e)}"
83 )
84 except Exception as e:
85 logger.error("Webhook processing failed", error=str(e))
86 raise HTTPException(
87 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
88 detail="Internal server error"
89 )
90
91 def _setup_middleware(self):
92 """Setup middleware for logging and error handling."""
93
94 @self.app.middleware("http")
95 async def log_requests(request: Request, call_next):
96 """Log all requests."""
97 client_host = request.client.host if request.client else "unknown"
98 logger.info(
99 "Request received",
100 method=request.method,
101 url=str(request.url),
102 client=client_host
103 )
104
105 try:
106 response = await call_next(request)
107 logger.info(
108 "Request completed",
109 method=request.method,
110 url=str(request.url),
111 status_code=response.status_code
112 )
113 return response
114 except Exception as e:
115 logger.error(
116 "Request failed",
117 method=request.method,
118 url=str(request.url),
119 error=str(e)
120 )
121 raise
122
123 def get_ssl_context(self) -> ssl.SSLContext:
124 """Create SSL context for HTTPS and mutual TLS."""
125 context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
126
127 # Load server certificate and key
128 cert_path = Path(self.server_config.cert_path) / self.server_config.domain
129 cert_file = cert_path / "fullchain.pem"
130 key_file = cert_path / "privkey.pem"
131
132 if not cert_file.exists() or not key_file.exists():
133 logger.error(
134 "SSL certificate files not found",
135 cert_file=str(cert_file),
136 key_file=str(key_file)
137 )
138 raise FileNotFoundError(f"SSL certificate files not found at {cert_path}")
139
140 context.load_cert_chain(str(cert_file), str(key_file))
141
142 # Configure mutual TLS if enabled
143 if self.server_config.enable_mtls:
144 if self.server_config.client_ca_path:
145 ca_path = Path(self.server_config.client_ca_path)
146 if ca_path.exists():
147 context.load_verify_locations(str(ca_path))
148 context.verify_mode = ssl.CERT_REQUIRED
149 logger.info("Mutual TLS enabled", ca_path=str(ca_path))
150 else:
151 logger.warning("Client CA file not found, disabling mutual TLS", ca_path=str(ca_path))
152 context.verify_mode = ssl.CERT_NONE
153 else:
154 logger.warning("No client CA path configured, disabling mutual TLS")
155 context.verify_mode = ssl.CERT_NONE
156 else:
157 context.verify_mode = ssl.CERT_NONE
158 logger.info("Mutual TLS disabled")
159
160 return context
161
162 def run(self):
163 """Run the webhook server with HTTPS and optional mutual TLS."""
164 try:
165 ssl_context = self.get_ssl_context()
166
167 logger.info(
168 "Starting Netdata Zulip webhook server",
169 host=self.server_config.host,
170 port=self.server_config.port,
171 domain=self.server_config.domain,
172 mtls_enabled=self.server_config.enable_mtls
173 )
174
175 uvicorn.run(
176 self.app,
177 host=self.server_config.host,
178 port=self.server_config.port,
179 ssl_context=ssl_context,
180 access_log=False, # We handle logging in middleware
181 )
182
183 except Exception as e:
184 logger.error("Failed to start server", error=str(e))
185 raise