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