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