Any signed-in account could save an ntfy channel with its own `server` and a
`{"$secret": ...}` token and press Test, which sent the stored secret to that
host. The config GET/PUT, the test route and the global search's alert entries
now answer to superusers, like /secrets. The webpush key and subscribe routes
stay open to every account, and the Alerts page shows a non-superuser only the
button for its own browser. The events table in the docs gains run_finished.
136 lines
4.9 KiB
Python
136 lines
4.9 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 webpush
|
|
from fluksio.flow.alerts import Alert, AlertsConfig
|
|
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)}",
|
|
)
|
|
|
|
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")
|
|
|
|
|
|
@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}'")
|