"""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.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.""" path = _path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(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}'")