Files
app/backend/fluksio/api/routes/alerts.py
T

252 lines
9.1 KiB
Python

"""Alerting configuration: which failures go where."""
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
FlowControllerDep,
get_current_active_superuser,
get_current_user,
)
from fluksio.core.config import settings
from fluksio.flow import telegram, webpush
from fluksio.flow.alerts import Alert, AlertsConfig
from fluksio.flow.identity import _replace
from fluksio.flow.secrets import SECRET_REF, SecretNotFound, resolve_params
from fluksio.models import Message
# Reading or changing where alerts go is a superuser's: a channel setting may
# hold a `{"$secret": ...}` reference that `send` resolves, and the url or
# server beside it is the sender's to choose, so an ordinary account could
# point a stored credential at a host of its own and press Test. The topic of
# an ntfy channel and a webhook's url are credentials too, so reading is gated
# as well. Subscribing a browser stays open to every account: it only says
# where *this* device can be reached.
router = APIRouter(
prefix="/alerts", tags=["alerts"], dependencies=[Depends(get_current_user)]
)
SUPERUSER = [Depends(get_current_active_superuser)]
def _path() -> Path:
return settings.ALERTS_FILE
def read_config() -> AlertsConfig:
"""The stored configuration, or an empty one. Blocking."""
path = _path()
if not path.exists():
return AlertsConfig()
try:
return AlertsConfig.model_validate_json(path.read_text())
except Exception:
# A hand-edited file that no longer parses must not stop the engine.
return AlertsConfig()
def write_config(config: AlertsConfig) -> None:
"""Blocking. Whole or not at all: a file cut short would read as no
configuration, which is alerting turned off."""
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
_replace(path, config.model_dump_json(indent=2))
@router.get("/config", response_model=AlertsConfig, dependencies=SUPERUSER)
async def read_alerts_config() -> Any:
"""What the engine alerts on, and where it sends it."""
return await run_in_threadpool(read_config)
@router.put("/config", response_model=AlertsConfig, dependencies=SUPERUSER)
async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep) -> Any:
"""Replace the configuration. Takes effect immediately."""
known = {channel.name for channel in body.channels}
for rule in body.rules:
missing = [name for name in rule.channels if name not in known]
if missing:
raise HTTPException(
status_code=422,
detail=f"No channel named {', '.join(repr(m) for m in missing)}",
)
for channel in body.channels:
token = channel.config.get("token")
# A bot token is the bot: it belongs in the secrets store, and this
# file is read by anyone who can read the data directory.
if (
channel.kind == "telegram"
and token
and not (isinstance(token, dict) and set(token) == {SECRET_REF})
):
raise HTTPException(
status_code=422,
detail=f"The bot token of '{channel.name}' has to be a stored secret",
)
await run_in_threadpool(write_config, body)
if controller.alerts is not None:
controller.alerts.config = body
return body
class WebPushKey(BaseModel):
key: str
class Unsubscribe(BaseModel):
endpoint: str
@router.get("/webpush/key", response_model=WebPushKey)
async def read_webpush_key() -> Any:
"""The key a browser subscribes against. Made on the first ask."""
return WebPushKey(key=await run_in_threadpool(webpush.public_key))
@router.post("/webpush/subscriptions", response_model=Message)
async def add_webpush_subscription(body: webpush.Subscription) -> Any:
"""Remember this browser, so a `webpush` channel can reach it."""
if not body.keys.get("p256dh") or not body.keys.get("auth"):
raise HTTPException(status_code=422, detail="The subscription has no keys")
await run_in_threadpool(webpush.add_subscription, body)
return Message(message="This browser will be notified")
@router.post("/webpush/unsubscribe", response_model=Message)
async def remove_webpush_subscription(body: Unsubscribe) -> Any:
"""Forget this browser."""
await run_in_threadpool(webpush.remove_subscription, body.endpoint)
return Message(message="This browser will no longer be notified")
class TelegramLink(BaseModel):
#: Opens the bot in a private chat; pressing Start connects that chat.
url: str
#: Asks which group to add the bot to; the group is connected instead.
group_url: str
expires_in: int
class TelegramLinkStatus(BaseModel):
#: A code is out and the engine is listening for it.
waiting: bool
expires_in: int = 0
#: Why it stopped listening, when it did not run out.
error: str = ""
def _store_chat(channel_name: str, chat_id: str, label: str) -> AlertsConfig | None:
"""Put a chat that proved itself into the stored channel. Blocking."""
config = read_config()
channel = next((c for c in config.channels if c.name == channel_name), None)
if channel is None:
# Renamed or removed while the code was out.
return None
channel.config = {**channel.config, "chat_id": chat_id, "label": label}
write_config(config)
return config
@router.post(
"/telegram/{channel_name}/link",
response_model=TelegramLink,
dependencies=SUPERUSER,
)
async def start_telegram_link(channel_name: str, controller: FlowControllerDep) -> Any:
"""A one-time link that connects whichever chat opens it first.
Typing a chat id would prove nothing about whose it is. Here the chat
sends the code back through the bot, so it is one somebody opened with a
link the operator handed out. Good for ten minutes, and for one chat.
"""
config = await run_in_threadpool(read_config)
channel = next(
(c for c in config.channels if c.name == channel_name and c.kind == "telegram"),
None,
)
if channel is None:
raise HTTPException(
status_code=404, detail=f"No telegram channel '{channel_name}'"
)
try:
token = resolve_params(channel.config.get("token"))
except (SecretNotFound, RuntimeError) as exc:
raise HTTPException(status_code=422, detail=str(exc))
if not token:
raise HTTPException(
status_code=422,
detail="Store the bot token and save the channel first",
)
try:
bot = await telegram.call(str(token), "getMe")
except telegram.TelegramError as exc:
raise HTTPException(status_code=502, detail=str(exc))
async def linked(name: str, chat_id: str, label: str) -> None:
stored = await run_in_threadpool(_store_chat, name, chat_id, label)
if stored is not None and controller.alerts is not None:
controller.alerts.config = stored
code = telegram.linker.start(channel_name, str(token), linked)
base = f"https://t.me/{bot['username']}"
return TelegramLink(
url=f"{base}?start={code}",
group_url=f"{base}?startgroup={code}",
expires_in=int(telegram.LINK_TTL_S),
)
@router.get(
"/telegram/{channel_name}/link",
response_model=TelegramLinkStatus,
dependencies=SUPERUSER,
)
async def read_telegram_link(channel_name: str) -> Any:
"""Whether a code for this channel is still waiting to be used."""
waiting, left, error = telegram.linker.status(channel_name)
return TelegramLinkStatus(waiting=waiting, expires_in=left, error=error)
@router.delete(
"/telegram/{channel_name}/link",
response_model=Message,
dependencies=SUPERUSER,
)
async def cancel_telegram_link(channel_name: str) -> Any:
"""Stop waiting for a chat to open the link."""
telegram.linker.cancel(channel_name)
return Message(message="Stopped waiting for a chat")
@router.post("/test/{channel_name}", response_model=Message, dependencies=SUPERUSER)
async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any:
"""Send one alert, to prove the channel works before relying on it."""
if controller.alerts is None:
raise HTTPException(status_code=503, detail="Alerting is not running")
config = controller.alerts.config
channel = next((c for c in config.channels if c.name == channel_name), None)
if channel is None:
raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'")
try:
await controller.alerts.send(
channel,
Alert(
title="Fluksio test alert",
body="If you are reading this, the channel works.",
severity="warning",
),
raise_on_error=True,
)
except Exception as exc:
# Whatever the sender said, verbatim: it is the only clue the operator
# has about why the channel does not work.
raise HTTPException(status_code=502, detail=str(exc) or type(exc).__name__)
return Message(message=f"Sent a test alert through '{channel_name}'")