diff --git a/docs/docs/logging.mdx b/docs/docs/logging.mdx index 63eb4fc..6776dde 100644 --- a/docs/docs/logging.mdx +++ b/docs/docs/logging.mdx @@ -43,14 +43,22 @@ If syslogging is enabled, all of the same log messages written to the file and/o If http logging is enabled, an HTTP POST will be sent to the configured target every time a query is submitted, _after_ it is validated. -| Parameter | Type | Default | Description | -| :----------- | :-----: | :------ | :-------------------------------------------------------------------------------------------------------- | -| `enable` | Boolean | `true` | Optionally disable webhooks even if configured. | -| `host` | String | | HTTP URL to webhook target. | -| `headers` | Mapping | | Any arbitrary mappings, which will be sent as HTTP headers. | -| `params` | Mapping | | Any arbitrary mappings, which will be sent as URL parameters (e.g. `http://example.com/log?param=value`). | -| `verify_ssl` | Boolean | `true` | Verify SSL certificate of target. | -| `timeout` | Integer | `5` | Time in seconds before request times out. | +| Parameter | Type | Default | Description | +| :----------- | :-----: | :---------- | :-------------------------------------------------------------------------------------------------------- | +| `enable` | Boolean | `true` | Optionally disable webhooks even if configured. | +| `host` | String | | HTTP URL to webhook target. | +| `headers` | Mapping | | Any arbitrary mappings, which will be sent as HTTP headers. | +| `params` | Mapping | | Any arbitrary mappings, which will be sent as URL parameters (e.g. `http://example.com/log?param=value`). | +| `verify_ssl` | Boolean | `true` | Verify SSL certificate of target. | +| `timeout` | Integer | `5` | Time in seconds before request times out. | +| `provider` | String | `'generic'` | Webhook provider. | + +### Supported Providers + +| Provider | Parameter Value | +| :-------------------------- | --------------: | +| [Slack](https://slack.com/) | `'slack'` | +| Generic | `'generic'` | ### Authentication @@ -68,7 +76,7 @@ If `api_key` is used, the header `X-API-Key: {key}` is added to the request, whe ### Webhook Data Structure -The webhook will POST JSON data in the following format: +If the `provider` field is set to `'generic'`, the webhook will POST JSON data in the following format: ```json { diff --git a/hyperglass/configuration/models/logging.py b/hyperglass/configuration/models/logging.py index 2b842a9..0894c43 100644 --- a/hyperglass/configuration/models/logging.py +++ b/hyperglass/configuration/models/logging.py @@ -21,8 +21,8 @@ from pydantic import ( ) # Project -from hyperglass.constants import __version__ from hyperglass.models import HyperglassModel, HyperglassModelExtra +from hyperglass.constants import __version__ class Syslog(HyperglassModel): @@ -53,11 +53,11 @@ class Http(HyperglassModelExtra): """HTTP logging parameters.""" enable: StrictBool = True + provider: constr(regex=r"(slack|generic)") = "generic" host: AnyHttpUrl authentication: Optional[HttpAuth] headers: Dict[StrictStr, Union[StrictStr, StrictInt, StrictBool, None]] = {} params: Dict[StrictStr, Union[StrictStr, StrictInt, StrictBool, None]] = {} - key: Optional[StrictStr] verify_ssl: StrictBool = True timeout: Union[StrictFloat, StrictInt] = 5.0 diff --git a/hyperglass/log.py b/hyperglass/log.py index 40da227..8aab21c 100644 --- a/hyperglass/log.py +++ b/hyperglass/log.py @@ -106,16 +106,19 @@ async def query_hook(query, http_logging, log): """Log a query to an http server.""" import httpx + from hyperglass.models import Webhook from hyperglass.util import parse_exception - if http_logging.key is not None: - query = {http_logging.key: query} + valid_webhook = Webhook(**query) - log.debug("Sending query data to webhook:\n{}", query) + format_map = {"generic": valid_webhook.export_dict, "slack": valid_webhook.slack} + format_func = format_map[http_logging.provider] async with httpx.AsyncClient(**http_logging.decoded()) as client: + payload = format_func() + log.debug("Sending query data to webhook:\n{}", payload) try: - response = await client.post(str(http_logging.host), json=query) + response = await client.post(str(http_logging.host), json=payload) if response.status_code not in range(200, 300): log.error(f"{response.status_code} error: {response.text}") diff --git a/hyperglass/models.py b/hyperglass/models.py index bfdaa54..1db5335 100644 --- a/hyperglass/models.py +++ b/hyperglass/models.py @@ -1,11 +1,14 @@ +"""Data models used throughout hyperglass.""" + # Standard Library import re -from typing import TypeVar +from typing import TypeVar, Optional # Third Party -from pydantic import HttpUrl, BaseModel, StrictInt, StrictFloat +from pydantic import HttpUrl, BaseModel, StrictInt, StrictStr, StrictFloat # Project +from hyperglass.log import log from hyperglass.util import clean_name IntFloat = TypeVar("IntFloat", StrictInt, StrictFloat) @@ -132,3 +135,103 @@ class StrictBytes(bytes): {str} -- Representation """ return f"StrictBytes({super().__repr__()})" + + +class WebhookHeaders(HyperglassModel): + """Webhook data model.""" + + content_length: Optional[StrictStr] + accept: Optional[StrictStr] + user_agent: Optional[StrictStr] + content_type: Optional[StrictStr] + referer: Optional[StrictStr] + accept_encoding: Optional[StrictStr] + accept_language: Optional[StrictStr] + + class Config: + """Pydantic model config.""" + + fields = { + "content_length": "content-length", + "user_agent": "user-agent", + "content_type": "content-type", + "accept_encoding": "accept-encoding", + "accept_language": "accept-language", + } + + +class WebhookNetwork(HyperglassModel): + """Webhook data model.""" + + prefix: Optional[StrictStr] + asn: Optional[StrictStr] + + +class Webhook(HyperglassModel): + """Webhook data model.""" + + query_location: StrictStr + query_type: StrictStr + query_vrf: StrictStr + query_target: StrictStr + headers: WebhookHeaders + source: StrictStr + network: WebhookNetwork + + def slack(self): + """Format the webhook data as a Slack message.""" + + def make_field(key, value, code=False): + if code: + value = f"`{value}`" + return f"*{key}*\n{value}" + + try: + header_data = [] + for k, v in self.headers.dict(by_alias=True).items(): + field = make_field(k, v, code=True) + header_data.append(field) + + query_details = ( + ("Query Location", self.query_location), + ("Query Type", self.query_type), + ("Query VRF", self.query_vrf), + ("Query Target", self.query_target), + ) + query_data = [] + for k, v in query_details: + field = make_field(k, v) + query_data.append({"type": "mrkdwn", "text": field}) + + source_details = ( + ("Source IP", self.source), + ("Source Prefix", self.network.prefix), + ("Source ASN", self.network.asn), + ) + + source_data = [] + for k, v in source_details: + field = make_field(k, v, code=True) + source_data.append({"type": "mrkdwn", "text": field}) + + payload = { + "text": "hyperglass received a valid query with the following data", + "blocks": [ + {"type": "section", "fields": query_data}, + {"type": "divider"}, + {"type": "section", "fields": source_data}, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Headers*\n" + "\n".join(header_data), + }, + }, + ], + } + log.debug("Created Slack webhook: {}", str(payload)) + except Exception as err: + log.error("Error while creating webhook: {}", str(err)) + payload = {} + return payload