Netdata.cloud bot for Zulip
1"""FastAPI webhook server for receiving Netdata notifications.""" 2 3import base64 4import hashlib 5import hmac 6from typing import Dict, Any 7 8import structlog 9import uvicorn 10from fastapi import FastAPI, HTTPException, Request, status, Query 11from fastapi.responses import JSONResponse 12 13from .formatter import ZulipMessageFormatter 14from .models import WebhookPayload, ZulipConfig, ServerConfig 15from .zulip_client import ZulipNotifier 16 17logger = structlog.get_logger() 18 19 20class NetdataWebhookServer: 21 """FastAPI server for handling Netdata Cloud webhooks.""" 22 23 def __init__(self, zulip_config: ZulipConfig, server_config: ServerConfig): 24 """Initialize the webhook server.""" 25 self.app = FastAPI( 26 title="Netdata Zulip Bot", 27 description="Webhook service for Netdata Cloud notifications", 28 version="0.1.0" 29 ) 30 self.zulip_config = zulip_config 31 self.server_config = server_config 32 self.formatter = ZulipMessageFormatter() 33 34 # Initialize Zulip client 35 try: 36 self.zulip_notifier = ZulipNotifier(zulip_config) 37 except Exception as e: 38 logger.error("Failed to initialize Zulip client", error=str(e)) 39 raise 40 41 self._setup_routes() 42 self._setup_middleware() 43 44 def _setup_routes(self): 45 """Setup FastAPI routes.""" 46 47 @self.app.get("/health") 48 async def health_check(): 49 """Health check endpoint.""" 50 return {"status": "healthy", "service": "netdata-zulip-bot"} 51 52 @self.app.get("/webhook/netdata") 53 async def netdata_webhook_challenge(crc_token: str = Query(...)): 54 """Handle Netdata Cloud webhook challenge for verification.""" 55 if not self.server_config.challenge_secret: 56 logger.error("Challenge secret not configured") 57 raise HTTPException( 58 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 59 detail="Challenge secret not configured" 60 ) 61 62 try: 63 # Create HMAC SHA-256 hash from crc_token and challenge secret 64 token_bytes = crc_token.encode('ascii') 65 secret_bytes = self.server_config.challenge_secret.encode('utf-8') 66 67 sha256_hash = hmac.new( 68 secret_bytes, 69 msg=token_bytes, 70 digestmod=hashlib.sha256 71 ).digest() 72 73 # Create response with base64 encoded hash 74 response_token = 'sha256=' + base64.b64encode(sha256_hash).decode('ascii') 75 76 logger.info("Responding to Netdata challenge", crc_token=crc_token[:16] + "...") 77 return {"response_token": response_token} 78 79 except Exception as e: 80 logger.error("Failed to process challenge", error=str(e)) 81 raise HTTPException( 82 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 83 detail="Failed to process challenge" 84 ) 85 86 @self.app.post("/webhook/netdata") 87 async def netdata_webhook(request: Request): 88 """Handle Netdata Cloud webhook notifications.""" 89 try: 90 # Get raw JSON data 91 body = await request.json() 92 logger.info("Received webhook", payload_keys=list(body.keys())) 93 94 # Parse and validate the payload 95 notification = WebhookPayload.parse(body) 96 logger.info("Parsed notification", type=type(notification).__name__) 97 98 # Format message for Zulip 99 topic, content = self.formatter.format_notification(notification) 100 logger.info("Formatted message", topic=topic) 101 102 # Send to Zulip 103 success = self.zulip_notifier.send_message(topic, content) 104 105 if success: 106 return {"status": "success", "message": "Notification sent to Zulip"} 107 else: 108 raise HTTPException( 109 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 110 detail="Failed to send notification to Zulip" 111 ) 112 113 except ValueError as e: 114 logger.error("Invalid webhook payload", error=str(e)) 115 raise HTTPException( 116 status_code=status.HTTP_400_BAD_REQUEST, 117 detail=f"Invalid payload format: {str(e)}" 118 ) 119 except Exception as e: 120 logger.error("Webhook processing failed", error=str(e)) 121 raise HTTPException( 122 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 123 detail="Internal server error" 124 ) 125 126 def _setup_middleware(self): 127 """Setup middleware for logging and error handling.""" 128 129 @self.app.middleware("http") 130 async def log_requests(request: Request, call_next): 131 """Log all requests.""" 132 client_host = request.client.host if request.client else "unknown" 133 logger.info( 134 "Request received", 135 method=request.method, 136 url=str(request.url), 137 client=client_host 138 ) 139 140 try: 141 response = await call_next(request) 142 logger.info( 143 "Request completed", 144 method=request.method, 145 url=str(request.url), 146 status_code=response.status_code 147 ) 148 return response 149 except Exception as e: 150 logger.error( 151 "Request failed", 152 method=request.method, 153 url=str(request.url), 154 error=str(e) 155 ) 156 raise 157 158 159 def run(self): 160 """Run the webhook server (HTTP only, TLS handled by reverse proxy).""" 161 try: 162 logger.info( 163 "Starting Netdata Zulip webhook server (HTTP)", 164 host=self.server_config.host, 165 port=self.server_config.port 166 ) 167 168 uvicorn.run( 169 self.app, 170 host=self.server_config.host, 171 port=self.server_config.port, 172 access_log=False, # We handle logging in middleware 173 ) 174 175 except Exception as e: 176 logger.error("Failed to start server", error=str(e)) 177 raise