# plugins/syslog/routes.py """The Syslog page: whether the listener is open, the sources whose lines are kept, and the senders offered to be added. Every GET reads Steward's own rows and the listener's state in memory; nothing here contacts a device (System 178's ruling). Adding, ignoring, editing and removing a source are an admin's, and audited.""" from __future__ import annotations from datetime import datetime, timezone from quart import Blueprint, abort, current_app, redirect, render_template, request, session from sqlalchemy import select from steward.auth.middleware import require_role from steward.core.audit import log_audit from steward.models.users import UserRole from .models import SyslogSource from .parse import severity_name from .receiver import LISTENER, MAX_SENDERS, settings syslog_bp = Blueprint("syslog", __name__, template_folder="templates") INDEX = "/plugins/syslog/" def _actor() -> tuple[str | None, str]: return session.get("user_id"), session.get("username", "") async def _hosts(db) -> list: from steward.models.hosts import Host return list((await db.execute(select(Host.id, Host.name, Host.address).order_by(Host.name))).all()) def listener_view(port: int, udp: bool, tcp: bool) -> dict: """What the page says about the listener: open on which protocols, or why not.""" protocols = [p for p, on in (("UDP", udp), ("TCP", tcp)) if on] return {"port": port, "protocols": protocols, "listening": LISTENER.listening, "error": LISTENER.error, "bound_at": LISTENER.bound_at, "received": LISTENER.received, "dropped": LISTENER.dropped, "unoffered": LISTENER.unoffered, "connections": LISTENER.connections, "senders_full": False, "max_senders": MAX_SENDERS} def source_view(s: SyslogSource, hosts: dict) -> dict: host = hosts.get(s.host_id) if s.host_id else None return {"id": s.id, "address": s.address, "state": s.state, "name": s.name or s.hostname_seen or s.address, "hostname_seen": s.hostname_seen, "host": host, "first_seen_at": s.first_seen_at, "last_seen_at": s.last_seen_at, "lines_total": s.lines_total or 0, "severity": severity_name(s.last_severity) if s.last_severity is not None else None, "level": s.last_severity, "last_line": s.last_line} def suggested_host(source: SyslogSource, hosts: list) -> str | None: """The host whose address is the sender's, if one is.""" return next((h.id for h in hosts if h.address == source.address), None) @syslog_bp.get("/") @require_role(UserRole.viewer) async def index(): async with current_app.db_sessionmaker() as db: rows = list((await db.execute( select(SyslogSource).order_by(SyslogSource.last_seen_at.desc()))).scalars()) hosts = await _hosts(db) by_id = {h.id: h for h in hosts} views = {state: [] for state in ("accepted", "offered", "ignored")} for row in rows: v = source_view(row, by_id) if row.state == "offered": v["suggested_host"] = suggested_host(row, hosts) views.setdefault(row.state, []).append(v) views["accepted"].sort(key=lambda v: v["name"].lower()) listener = listener_view(*settings(current_app)) listener["senders_full"] = len(rows) >= MAX_SENDERS return await render_template("syslog/index.html", listener=listener, sources=views["accepted"], offered=views["offered"], ignored=views["ignored"], hosts=hosts, now=datetime.now(timezone.utc)) async def _source_or_404(db, source_id: str) -> SyslogSource: source = await db.get(SyslogSource, source_id) if source is None: abort(404) return source async def _host_id(db, value: str | None) -> str | None: from steward.models.hosts import Host if not value: return None return value if await db.get(Host, value) is not None else None @syslog_bp.post("/sources//add") @require_role(UserRole.admin) async def add_source(source_id: str): """Keep an offered sender's lines from now on, under the name given.""" form = await request.form async with current_app.db_sessionmaker() as db: async with db.begin(): source = await _source_or_404(db, source_id) source.name = (form.get("name") or "").strip()[:100] or source.hostname_seen or source.address source.host_id = await _host_id(db, form.get("host_id")) source.state = "accepted" detail = {"name": source.name, "address": source.address} await log_audit(current_app, *_actor(), "syslog.source_added", entity_type="syslog_source", entity_id=source_id, detail=detail) return redirect(INDEX) @syslog_bp.post("/sources//ignore") @require_role(UserRole.admin) async def ignore_source(source_id: str): """Stop offering a sender: it is still counted, never kept.""" return await _set_state(source_id, "ignored", "syslog.source_ignored") @syslog_bp.post("/sources//offer") @require_role(UserRole.admin) async def offer_source(source_id: str): """Offer an ignored sender again.""" return await _set_state(source_id, "offered", "syslog.source_offered") async def _set_state(source_id: str, state: str, action: str): async with current_app.db_sessionmaker() as db: async with db.begin(): source = await _source_or_404(db, source_id) source.state = state detail = {"address": source.address} await log_audit(current_app, *_actor(), action, entity_type="syslog_source", entity_id=source_id, detail=detail) return redirect(INDEX) @syslog_bp.get("/sources//edit") @require_role(UserRole.admin) async def edit_source(source_id: str): async with current_app.db_sessionmaker() as db: source = await _source_or_404(db, source_id) hosts = await _hosts(db) return await render_template("syslog/edit.html", source=source, hosts=hosts, label=source.name or source.hostname_seen or source.address) @syslog_bp.post("/sources//edit") @require_role(UserRole.admin) async def save_source(source_id: str): form = await request.form async with current_app.db_sessionmaker() as db: async with db.begin(): source = await _source_or_404(db, source_id) old_name = source.name source.name = (form.get("name") or "").strip()[:100] or source.hostname_seen or source.address source.host_id = await _host_id(db, form.get("host_id")) detail = {"name": source.name} if old_name != source.name: detail["old_name"] = old_name await log_audit(current_app, *_actor(), "syslog.source_updated", entity_type="syslog_source", entity_id=source_id, detail=detail) return redirect(INDEX) @syslog_bp.post("/sources//delete") @require_role(UserRole.admin) async def delete_source(source_id: str): """Remove a source and the lines kept from it. A device still sending is offered again.""" async with current_app.db_sessionmaker() as db: async with db.begin(): source = await _source_or_404(db, source_id) detail = {"name": source.name, "address": source.address} await db.delete(source) await log_audit(current_app, *_actor(), "syslog.source_removed", entity_type="syslog_source", entity_id=source_id, detail=detail) return redirect(INDEX)