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