252 lines
9.1 KiB
Python
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}'")
|