main
py 429 lines 16 KB
Raw
1 import time
2 from typing import Any
3 from typing import Dict
4 from typing import Optional
5 from typing import Tuple
6
7 import requests
8 from fastapi import HTTPException
9 from loguru import logger
10
11 from app.connectors.graylog.utils.routing import get_current_graylog_connector
12 from app.connectors.utils import get_connector_info_from_db
13 from app.db.db_session import get_db_session
14
15 HEADERS = {"X-Requested-By": "CoPilot"}
16
17 # Cache of the detected Graylog major version, keyed by connector name.
18 # Graylog 7.0 introduced breaking changes to entity-creation POSTs (the
19 # CreateEntityRequest wrapper) and renamed urlwhitelist -> urlallowlist, so we
20 # branch on the server's major version. The short TTL lets an in-place Graylog
21 # upgrade be picked up without restarting CoPilot.
22 _GRAYLOG_VERSION_CACHE: Dict[str, Tuple[int, float]] = {}
23 _GRAYLOG_VERSION_TTL_SECONDS = 300
24
25
26 async def verify_graylog_credentials(attributes: Dict[str, Any]) -> Dict[str, Any]:
27 """
28 Verifies the connection to Graylog service.
29
30 Returns:
31 dict: A dictionary containing 'connectionSuccessful' status and 'authToken' if the connection is successful.
32 """
33 logger.info(
34 f"Verifying the graylog connection to {attributes['connector_url']}",
35 )
36 try:
37 graylog_roles = requests.get(
38 f"{attributes['connector_url']}/api/authz/roles/user/{attributes['connector_username']}",
39 auth=(
40 attributes["connector_username"],
41 attributes["connector_password"],
42 ),
43 verify=False,
44 )
45 if graylog_roles.status_code == 200:
46 logger.info(
47 f"Connection to {attributes['connector_url']} successful",
48 )
49 return {
50 "connectionSuccessful": True,
51 "message": "Graylog connection successful",
52 }
53 else:
54 logger.error(
55 f"Connection to {attributes['connector_url']} failed with error: {graylog_roles.text}",
56 )
57 return {
58 "connectionSuccessful": False,
59 "message": f"Connection to {attributes['connector_url']} failed with error: {graylog_roles.text}",
60 }
61 except Exception as e:
62 logger.error(
63 f"Connection to {attributes['connector_url']} failed with error: {e}",
64 )
65 return {
66 "connectionSuccessful": False,
67 "message": f"Connection to {attributes['connector_url']} failed with error: {e}",
68 }
69
70
71 async def verify_graylog_connection(connector_name: str) -> str:
72 """
73 Returns if connection to Graylog service is successful.
74 """
75 logger.info("Getting Graylog authentication token")
76 async with get_db_session() as session: # This will correctly enter the context manager
77 attributes = await get_connector_info_from_db(connector_name, session)
78 if attributes is None:
79 logger.error("No Graylog connector found in the database")
80 return None
81 return await verify_graylog_credentials(attributes)
82
83
84 async def send_get_request(
85 endpoint: str,
86 params: Optional[Dict[str, Any]] = None,
87 # connector_name: str = "Graylog",
88 connector_name: Optional[str] = None,
89 ) -> Dict[str, Any]:
90 """
91 Sends a GET request to the Graylog service.
92
93 Args:
94 endpoint (str): The endpoint to send the GET request to.
95 params (Optional[Dict[str, Any]], optional): The parameters to send with the GET request.
96 connector_name (Optional[str], optional): The name of the connector to use.
97 If not provided, uses the current context or defaults to "Graylog".
98
99 Returns:
100 Dict[str, Any]: The response from the GET request.
101 """
102 # Use provided connector_name, or fall back to context-based resolution
103 if connector_name is None:
104 connector_name = get_current_graylog_connector()
105
106 logger.info(f"Sending GET request to {endpoint} using connector: {connector_name}")
107 async with get_db_session() as session:
108 attributes = await get_connector_info_from_db(connector_name, session)
109 if attributes is None:
110 raise HTTPException(status_code=500, detail=f"Connector {connector_name} not found")
111 try:
112 response = requests.get(
113 f"{attributes['connector_url']}{endpoint}",
114 headers=HEADERS,
115 auth=(
116 attributes["connector_username"],
117 attributes["connector_password"],
118 ),
119 params=params,
120 verify=False,
121 )
122 if response.status_code == 404:
123 raise HTTPException(
124 status_code=404,
125 detail=f"Failed to send GET request to {endpoint} with error: {response.json()['message']}",
126 )
127 return {
128 "data": response.json(),
129 "success": True,
130 "message": "Successfully retrieved data",
131 }
132 except HTTPException as e:
133 raise e
134 except Exception as e:
135 raise HTTPException(
136 status_code=500,
137 detail=f"Failed to send GET request to {endpoint} with error: {e}",
138 )
139
140
141 async def send_post_request(
142 endpoint: str,
143 data: Dict[str, Any] = None,
144 # connector_name: str = "Graylog",
145 connector_name: Optional[str] = None,
146 ) -> Dict[str, Any]:
147 """
148 Sends a POST request to the Graylog service.
149
150 Args:
151 endpoint (str): The endpoint to send the POST request to.
152 data (Dict[str, Any]): The data to send with the POST request.
153 connector_name (Optional[str], optional): The name of the connector to use.
154 If not provided, uses the current context or defaults to "Graylog".
155
156 Returns:
157 Dict[str, Any]: The response from the POST request.
158 """
159 # Use provided connector_name, or fall back to context-based resolution
160 if connector_name is None:
161 connector_name = get_current_graylog_connector()
162
163 logger.info(f"Sending POST request to {endpoint} using connector: {connector_name}")
164 async with get_db_session() as session:
165 attributes = await get_connector_info_from_db(connector_name, session)
166 if attributes is None:
167 raise HTTPException(status_code=500, detail=f"Connector {connector_name} not found")
168
169 try:
170 response = requests.post(
171 f"{attributes['connector_url']}{endpoint}",
172 headers=HEADERS,
173 auth=(
174 attributes["connector_username"],
175 attributes["connector_password"],
176 ),
177 json=data,
178 verify=False,
179 )
180 logger.info(
181 f"Response from POST request: {response.status_code} {response.text}",
182 )
183
184 if response.status_code == 200:
185 return {
186 "data": response.json(),
187 "success": True,
188 "message": "Successfully completed request",
189 }
190 elif response.status_code == 204:
191 return {
192 "data": None,
193 "success": True,
194 "message": "Successfully completed request with no content",
195 }
196 elif response.status_code == 201:
197 try:
198 return {
199 "data": response.json(),
200 "success": True,
201 "message": "Successfully created data",
202 }
203 except ValueError:
204 return {
205 "data": None,
206 "success": True,
207 "message": "Successfully created data, but no data returned",
208 }
209 else:
210 raise HTTPException(
211 status_code=500,
212 detail=f"Failed to send POST request to {endpoint} with error: {response.json()['message']}",
213 )
214 except HTTPException as e:
215 raise e
216 except Exception as e:
217 raise HTTPException(
218 status_code=500,
219 detail=f"Failed to send POST request to {endpoint} with error: {e}",
220 )
221
222
223 async def send_delete_request(
224 endpoint: str,
225 params: Optional[Dict[str, Any]] = None,
226 # connector_name: str = "Graylog",
227 connector_name: Optional[str] = None,
228 ) -> Dict[str, Any]:
229 """
230 Sends a DELETE request to the Graylog service.
231
232 Args:
233 endpoint (str): The endpoint to send the DELETE request to.
234 params (Optional[Dict[str, Any]], optional): The parameters to send with the DELETE request.
235 connector_name (Optional[str], optional): The name of the connector to use.
236 If not provided, uses the current context or defaults to "Graylog".
237
238 Returns:
239 Dict[str, Any]: The response from the DELETE request.
240 """
241 # Use provided connector_name, or fall back to context-based resolution
242 if connector_name is None:
243 connector_name = get_current_graylog_connector()
244
245 logger.info(f"Sending DELETE request to {endpoint} using connector: {connector_name}")
246 async with get_db_session() as session:
247 attributes = await get_connector_info_from_db(connector_name, session)
248 if attributes is None:
249 raise HTTPException(status_code=500, detail=f"Connector {connector_name} not found")
250 try:
251 response = requests.delete(
252 f"{attributes['connector_url']}{endpoint}",
253 headers=HEADERS,
254 auth=(
255 attributes["connector_username"],
256 attributes["connector_password"],
257 ),
258 params=params,
259 verify=False,
260 )
261 if response.status_code != 200 and response.status_code != 204:
262 raise HTTPException(
263 status_code=404,
264 detail=f"Failed to send DELETE request to {endpoint} with error: {response.json()['message']}",
265 )
266 return {
267 "data": "No content returned",
268 "success": True,
269 "message": "Successfully deleted data",
270 }
271 except HTTPException as e:
272 raise e
273 except Exception as e:
274 logger.error(f"Failed to send DELETE request to {endpoint} with error: {e}")
275 return {
276 "success": False,
277 "message": f"Failed to send DELETE request to {endpoint} with error: {e}",
278 }
279
280
281 async def send_put_request(
282 endpoint: str,
283 data: Optional[Dict[str, Any]] = None,
284 # connector_name: str = "Graylog",
285 connector_name: Optional[str] = None,
286 ) -> Dict[str, Any]:
287 """
288 Sends a PUT request to the Graylog service.
289
290 Args:
291 endpoint (str): The endpoint to send the PUT request to.
292 data (Optional[Dict[str, Any]], optional): The data to send with the PUT request.
293 connector_name (Optional[str], optional): The name of the connector to use.
294 If not provided, uses the current context or defaults to "Graylog".
295
296 Returns:
297 Dict[str, Any]: The response from the PUT request.
298 """
299 # Use provided connector_name, or fall back to context-based resolution
300 if connector_name is None:
301 connector_name = get_current_graylog_connector()
302
303 logger.info(f"Sending PUT request to {endpoint} using connector: {connector_name}")
304 async with get_db_session() as session: # This will correctly enter the context manager
305 attributes = await get_connector_info_from_db(connector_name, session)
306 if attributes is None:
307 logger.error("No Graylog connector found in the database")
308 return None
309 try:
310 response = requests.put(
311 f"{attributes['connector_url']}{endpoint}",
312 headers=HEADERS,
313 auth=(
314 attributes["connector_username"],
315 attributes["connector_password"],
316 ),
317 json=data,
318 verify=False,
319 )
320 logger.info(
321 f"Response from PUT request: {response.status_code} {response.text}",
322 )
323 if response.status_code not in [200, 204]:
324 raise HTTPException(
325 status_code=404,
326 detail=f"Failed to send PUT request to {endpoint} with error: {response.json().get('message', '')}",
327 )
328 if response.status_code == 204:
329 return {
330 "data": None,
331 "success": True,
332 "message": "Successfully sent PUT request, no content returned",
333 }
334 return {
335 "data": response.json(),
336 "success": True,
337 "message": "Successfully retrieved data",
338 }
339 except HTTPException as e:
340 raise e
341 except Exception as e:
342 logger.error(f"Failed to send PUT request to {endpoint} with error: {e}")
343 return {
344 "success": False,
345 "message": f"Failed to send PUT request to {endpoint} with error: {e}",
346 }
347
348
349 async def get_graylog_major_version(connector_name: Optional[str] = None) -> int:
350 """
351 Detects the major version of the configured Graylog server.
352
353 Graylog 7.0 changed several entity-creation requests in a backwards-incompatible
354 way (see ``send_post_request_create_entity``). Callers use this to build the
355 correct request shape for the running server. The result is cached per connector
356 for a short TTL.
357
358 Falls back to major version ``6`` when the version cannot be determined, so
359 existing Graylog 6.x deployments keep working unchanged.
360
361 Args:
362 connector_name (Optional[str]): The connector to probe. Falls back to the
363 current context when not provided.
364
365 Returns:
366 int: The detected Graylog major version (e.g. ``6`` or ``7``).
367 """
368 if connector_name is None:
369 connector_name = get_current_graylog_connector()
370
371 cached = _GRAYLOG_VERSION_CACHE.get(connector_name)
372 now = time.monotonic()
373 if cached is not None and now - cached[1] < _GRAYLOG_VERSION_TTL_SECONDS:
374 return cached[0]
375
376 major = 6 # safe default — preserves pre-7.x behavior on detection failure
377 try:
378 response = await send_get_request(endpoint="/api/system", connector_name=connector_name)
379 # version looks like "7.0.1+abc123" or "6.1.4"
380 version_str = str(response["data"]["version"])
381 major = int(version_str.split("+")[0].split(".")[0].strip())
382 except Exception as e:
383 logger.warning(
384 f"Could not determine Graylog version for connector '{connector_name}', " f"defaulting to major version {major}: {e}",
385 )
386
387 _GRAYLOG_VERSION_CACHE[connector_name] = (major, now)
388 logger.info(f"Detected Graylog major version {major} for connector '{connector_name}'")
389 return major
390
391
392 async def send_post_request_create_entity(
393 endpoint: str,
394 entity: Dict[str, Any],
395 share_request: Optional[Dict[str, Any]] = None,
396 connector_name: Optional[str] = None,
397 ) -> Dict[str, Any]:
398 """
399 Sends an entity-creation POST request, adapting the body to the Graylog version.
400
401 Graylog 7.0 wraps entity-creation payloads in a ``CreateEntityRequest``
402 (``{"entity": {...}, "share_request": {...}}``); Graylog 6.x expects the entity
403 fields at the top level. Neither version accepts the other's shape, so this
404 helper builds the correct body based on the detected server version, letting a
405 single call site support both Graylog 6 and Graylog 7.
406
407 Confirmed affected endpoints: ``POST /api/streams`` and content-pack installation
408 (``POST /api/system/content_packs/{id}/{revision}/installations``). Index-set
409 creation is NOT wrapped in either version and must keep using
410 ``send_post_request`` directly.
411
412 Args:
413 endpoint (str): The entity-creation endpoint.
414 entity (Dict[str, Any]): The entity fields (the flat Graylog 6.x body).
415 share_request (Optional[Dict[str, Any]]): Optional Graylog 7.x sharing
416 settings; omitted when ``None`` (it is nullable server-side).
417 connector_name (Optional[str]): The connector to use. Falls back to context.
418
419 Returns:
420 Dict[str, Any]: The response from the POST request.
421 """
422 major = await get_graylog_major_version(connector_name)
423 if major >= 7:
424 body: Dict[str, Any] = {"entity": entity}
425 if share_request is not None:
426 body["share_request"] = share_request
427 else:
428 body = entity
429 return await send_post_request(endpoint=endpoint, data=body, connector_name=connector_name)