diff --git a/.pylintrc b/.pylintrc index 041c69d..c5b3cdd 100644 --- a/.pylintrc +++ b/.pylintrc @@ -441,7 +441,9 @@ disable=raw-checker-failed, singleton-comparison, missing-module-docstring, missing-class-docstring, - missing-function-docstring + missing-function-docstring, + too-many-arguments, + too-many-locals, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/README.md b/README.md index 73464d3..6e48daa 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ Internally, a HTTP API is exposed, and a job queue is used to distribute the che - [X] donner un aperçu rapide de l’état de la supervision. - [ ] Use background tasks for alerting (#23) - [ ] Delete outdated tasks from config (#19, !25) -- [ ] Implement alerting tasks (#15, 16, !13) -- [ ] Handles multiple alerting backends (email, sms, gotify) (!13) +- [X] Implement alerting tasks (#15, 16, !13) +- [X] Handles multiple alerting backends (email, sms, gotify) (!13) - [ ] add an "unknown" severity for check errors (!17) - [ ] Add a command to generate new authentication token (#22) - [ ] Add a way to specify the severity of the alerts in the config - [ ] Allow passing a dict to check -- [ ] Un flag de configuration permet d’ajouter automatiquement un job de vérification de redirection 301 de la version HTTP vers HTTPS +- [ ] A configuration flag can automatically add a check of 301 redirection from HTTP to HTTPS ## License diff --git a/argos/schemas/config.py b/argos/schemas/config.py index e7a7825..160b5ae 100644 --- a/argos/schemas/config.py +++ b/argos/schemas/config.py @@ -4,7 +4,15 @@ For database models, see argos.server.models. """ from typing import Dict, List, Literal, Optional, Tuple -from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + HttpUrl, + StrictBool, + EmailStr, + PositiveInt, + field_validator, +) from pydantic.functional_validators import BeforeValidator from typing_extensions import Annotated @@ -93,6 +101,23 @@ class Service(BaseModel): secrets: List[str] +class MailAuth(BaseModel): + """Mail authentication configuration""" + login: str + password: str + + +class Mail(BaseModel): + """Mail configuration""" + mailfrom: EmailStr + host: str = '127.0.0.1' + port: PositiveInt = 25 + ssl: StrictBool = False + starttls: StrictBool = False + auth: Optional[MailAuth] = None + addresses: List[EmailStr] + + class Alert(BaseModel): """List of way to handle alerts, by severity""" @@ -102,11 +127,18 @@ class Alert(BaseModel): unknown: List[str] +class GotifyUrl(BaseModel): + url: HttpUrl + tokens: List[str] + + class General(BaseModel): """Frequency for the checks and alerts""" frequency: int alerts: Alert + mail: Optional[Mail] = None + gotify: Optional[List[GotifyUrl]] = None @field_validator("frequency", mode="before") def parse_frequency(cls, value): diff --git a/argos/server/alerting.py b/argos/server/alerting.py index 5f5821a..7c07533 100644 --- a/argos/server/alerting.py +++ b/argos/server/alerting.py @@ -1,9 +1,125 @@ +import ssl +import smtplib + +from typing import List +from urllib.parse import urlparse + +import httpx + +from argos.checks.base import Severity from argos.logging import logger +from argos.schemas.config import Config, Mail, GotifyUrl # XXX Implement mail alerts https://framagit.org/framasoft/framaspace/argos/-/issues/15 # XXX Implement gotify alerts https://framagit.org/framasoft/framaspace/argos/-/issues/16 -def handle_alert(config, result, task, severity): +def handle_alert(config: Config, result, task, severity, old_severity, request): """Dispatch alert through configured alert channels""" - msg = f"task={task.id}, status={result.status}, {severity=}" - logger.error("Alerting stub: %s", msg) + + if 'local' in getattr(config.general.alerts, severity): + logger.error("Alerting stub: task=%i, status=%s, severity=%s", + task.id, + result.status, + severity) + + if config.general.mail is not None and \ + 'mail' in getattr(config.general.alerts, severity): + notify_by_mail(result, task, severity, old_severity, config.general.mail, request) + + if config.general.gotify is not None and \ + 'gotify' in getattr(config.general.alerts, severity): + notify_with_gotify(result, task, severity, old_severity, config.general.gotify, request) + + +def notify_by_mail(result, task, severity: str, old_severity: str, config: Mail, request) -> None: + logger.debug('Will send mail notification') + + msg = f"""\ +URL: {task.url} +Check: {task.check} +Status: {severity} +Time: {result.submitted_at} +Previous status: {old_severity} + +See results of task on {request.url_for('get_task_results', task_id=task.id)} +""" + + mail = f"""\ +Subject: [Argos] {urlparse(task.url).netloc}: status {severity} + +{msg}""" + + if config.ssl: + logger.debug('Mail notification: SSL') + context = ssl.create_default_context() + smtp = smtplib.SMTP_SSL(host=config.host, + port=config.port, + context=context) + else: + smtp = smtplib.SMTP(host=config.host, # type: ignore + port=config.port) + if config.starttls: + logger.debug('Mail notification: STARTTLS') + context = ssl.create_default_context() + smtp.starttls(context=context) + + if config.auth is not None: + logger.debug('Mail notification: authentification') + smtp.login(config.auth.login, + config.auth.password) + + for address in config.addresses: + logger.debug('Sending mail to %s', address) + logger.debug(msg) + smtp.sendmail(config.mailfrom, address, mail) + + +def notify_with_gotify( + result, + task, + severity: str, + old_severity: str, + config: List[GotifyUrl], + request +) -> None: + logger.debug('Will send gotify notification') + headers = {'accept': 'application/json', + 'content-type': 'application/json'} + + priority = 9 + icon = '❌' + if severity == Severity.OK: + priority = 1 + icon = '✅' + elif severity == Severity.WARNING: + priority = 5 + icon = '⚠️' + + subject = f"{icon} {urlparse(task.url).netloc}: status {severity}" + msg = f"""\ +URL: {task.url} +Check: {task.check} +Status: {severity} +Time: {result.submitted_at} +Previous status: {old_severity} + +See results of task on {request.url_for('get_task_results', task_id=task.id)} +""" + + payload = {'title': subject, + 'message': msg, + 'priority': priority} + + for url in config: + logger.debug('Sending gotify message(s) to %s', url) + for token in url.tokens: + try: + res = httpx.post(f"{url.url}message", + params={'token': token}, + headers=headers, + json=payload) + res.raise_for_status() + except httpx.RequestError as err: + logger.error('An error occurred while sending a message to %s with token %s', + err.request.url, + token) diff --git a/argos/server/routes/api.py b/argos/server/routes/api.py index 57001c6..244f499 100644 --- a/argos/server/routes/api.py +++ b/argos/server/routes/api.py @@ -44,19 +44,23 @@ async def create_results( agent_id = agent_id or request.client.host db_results = [] for agent_result in results: - result = await queries.create_result(db, agent_result, agent_id) # XXX Maybe offload this to a queue. # XXX Get all the tasks at once, to limit the queries on the db task = await queries.get_task(db, agent_result.task_id) if not task: logger.error("Unable to find task %i", agent_result.task_id) else: + last_severity = task.severity + result = await queries.create_result(db, agent_result, agent_id) check = task.get_check() status, severity = await check.finalize(config, result, **result.context) result.set_status(status, severity) task.set_times_and_deselect() - handle_alert(config, result, task, severity) + # Don’t create an alert if the severity has not changed + if last_severity != severity: + # XXX Use a job queue or make it async + handle_alert(config, result, task, severity, last_severity, request) db_results.append(result) db.commit() diff --git a/argos/server/templates/index.html b/argos/server/templates/index.html index 9d347ef..e1b41bf 100644 --- a/argos/server/templates/index.html +++ b/argos/server/templates/index.html @@ -12,7 +12,7 @@ {{ counts_dict['ok'] }}
-
⚠️ Warning
+
⚠️ Warning
{{ counts_dict['warning'] }}
diff --git a/config-example.yaml b/config-example.yaml index d2afa2a..8bbf908 100644 --- a/config-example.yaml +++ b/config-example.yaml @@ -10,6 +10,24 @@ general: - local unknown: - local +# mail: +# mailfrom: no-reply@example.org +# host: 127.0.0.1 +# port: 25 +# ssl: False +# starttls: False +# auth: +# login: foo +# password: bar +# addresses: +# - foo@admin.example.org +# - bar@admin.example.org +# gotify: +# - url: https://example.org +# tokens: +# - foo +# - bar + service: secrets: # Secrets can be generated using `openssl rand -base64 32`. diff --git a/pyproject.toml b/pyproject.toml index f4a47ff..67da05e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "click>=8.1,<9", "fastapi>=0.103,<0.104", "httpx>=0.25,<1", - "pydantic>=2.4,<3", + "pydantic[email]>=2.4,<3", "pyyaml>=6.0,<7", "pyyaml-include>=1.3,<2", "sqlalchemy[asyncio]>=2.0,<3",