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