- plugins/syslog: RFC 5424 and RFC 3164 parsing, RFC 6587 TCP framing. - Lines are queued as they arrive and stored every 5 s through a Holding, so a storage stall delays them rather than losing them. - A new sender is an offered source, counted and not kept, until an admin adds it on the Syslog page (name, host); it can be ignored instead. - The listener follows the plugin's settings (port, UDP, TCP), rebinding within seconds of a save; a port it can't open is said on the page. - Hourly ring per source: age window and byte cap from Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
7.5 KiB
Python
183 lines
7.5 KiB
Python
# 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/<source_id>/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/<source_id>/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/<source_id>/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/<source_id>/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/<source_id>/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/<source_id>/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)
|