Files
FabledSteward/plugins/syslog/routes.py
T
bvandeusenandClaude Opus 5.5 ed9da56efa
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m38s
CI / publish (push) Successful in 20s
feat(syslog): set-up help on the page, port 5514 published, Docker-hidden senders called out (#6003)
The Syslog page says where to send (this server, its port and protocols) and
where the setting is on Synology, QNAP, TrueNAS, OpenWrt, UniFi and rsyslog,
open until a device sends. A sender whose address is on Steward's own
container network (Swarm ingress publishing hides the real sender) is
flagged, with the host-mode ports to publish instead. compose files and the
Dockerfile publish 5514/udp and /tcp; docs/plugins/syslog.md describes the
plugin and the deploy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 07:01:56 -04:00

515 lines
24 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, timedelta, timezone
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from sqlalchemy import delete, select
from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.core.metric_series import latest_by_resource
from steward.core.time_range import RANGES
from steward.models.metrics import PluginMetricLatest
from steward.models.users import UserRole
from .models import SyslogLine, SyslogPattern, SyslogSource
from .network import on_own_network
from .parse import SEVERITIES, facility_name, severity_name
from .patterns import (PRESETS, PRESETS_BY_KEY, SOURCE_MODULE, WINDOWS, PatternError, key_for, parse_form,
resource_of)
from .receiver import LISTENER, MAX_SENDERS, settings
from .service import (AROUND_LINES, AROUND_SEVERITY, MARK_SEVERITY, MAX_MARKS, PANEL_LINES, PANEL_SPAN,
SEVERITY_CHOICES, apps_of, filtered, line_filter, lines_between, place_for, rates, shown_on,
source_name)
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, rate: int | None = None) -> dict:
host = hosts.get(s.host_id) if s.host_id else None
return {"id": s.id, "address": s.address, "state": s.state, "rate": rate,
"docker_address": on_own_network(s.address),
"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)
per_hour = await rates(db, [r.id for r in rows if r.state == "accepted"])
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, per_hour.get(row.id, 0) if row.state == "accepted" else None)
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,
server=request.host.rsplit(":", 1)[0] if request.host.count(":") == 1 else request.host,
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 _unique_name(db, source: SyslogSource, wanted: str) -> str:
"""The name a source is given, made its own: a source's pattern counts
and the alert rules on them are filed under its name, so two sources
sharing one would read as one. A name taken is kept with the address."""
name = wanted.strip()[:100] or source.hostname_seen or source.address
taken = (await db.execute(select(SyslogSource.id).where(
SyslogSource.name == name, SyslogSource.id != source.id).limit(1))).first()
return f"{name[:80]} ({source.address})" if taken else name
async def _move_resource(db, old: str, new: str) -> None:
"""Move a renamed source's counts, and the alert rules on them, to its new name."""
from sqlalchemy import update
from steward.models.alerts import AlertRule
from steward.models.metrics import PluginMetric, PluginMetricLatest
for model in (PluginMetric, PluginMetricLatest, AlertRule):
await db.execute(update(model).where(
model.source_module == SOURCE_MODULE, model.resource_name == old).values(resource_name=new))
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 = await _unique_name(db, source, form.get("name") or "")
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_resource = resource_of(source)
old_name = source.name
source.name = await _unique_name(db, source, form.get("name") or "")
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
if old_resource != resource_of(source):
await _move_resource(db, old_resource, resource_of(source))
await log_audit(current_app, *_actor(), "syslog.source_updated", entity_type="syslog_source",
entity_id=source_id, detail=detail)
return redirect(f"/plugins/syslog/sources/{source_id}")
@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}
resource = resource_of(source)
await db.delete(source)
# Its newest pattern counts go with it; its history ages out.
await db.execute(delete(PluginMetricLatest).where(
PluginMetricLatest.source_module == SOURCE_MODULE, PluginMetricLatest.resource_name == resource))
await log_audit(current_app, *_actor(), "syslog.source_removed", entity_type="syslog_source",
entity_id=source_id, detail=detail)
return redirect(INDEX)
# ── A source's lines, and the panel on a host's or an SNMP device's page ─────
def line_view(line) -> dict:
return {"id": line.id, "received_at": line.received_at, "sent_at": line.sent_at,
"level": line.severity, "severity": severity_name(line.severity),
"facility": facility_name(line.facility), "hostname": line.hostname,
"app": line.app, "message": line.message}
@syslog_bp.get("/sources/<source_id>")
@require_role(UserRole.viewer)
async def source_detail(source_id: str):
"""One source's kept lines, newest first, a page at a time as the reader
scrolls (core.paging), under a filter bar: severity and worse, app, text
and time. ?before=<cursor> is the next page's rows alone; a request from
the filter bar is the table alone."""
from urllib.parse import urlencode
from steward.audit.service import mark_day_groups
from steward.core import formatting as fmt
from steward.core.paging import keyset, next_page
f = line_filter(request.args)
before = request.args.get("before")
now = datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
source = await _source_or_404(db, source_id)
stmt = keyset(filtered(select(SyslogLine).where(SyslogLine.source_id == source.id), f, now),
SyslogLine.received_at, SyslogLine.id, before)
lines, after = next_page((await db.execute(stmt)).scalars().all(), lambda ln: (ln.received_at, ln.id))
page_only = bool(before) or bool(request.headers.get("HX-Request"))
hosts = {} if page_only else {h.id: h for h in await _hosts(db)}
apps = [] if page_only else await apps_of(db, source.id, f.since(now))
per_hour = {} if page_only else await rates(db, [source.id], now)
rows = [line_view(ln) for ln in lines]
last_group = mark_day_groups(rows, fmt.zone(session.get("tz")), request.args.get("group", ""),
at=lambda r: r["received_at"])
base = f"/plugins/syslog/sources/{source_id}?"
ctx = {"source": source_view(source, hosts, per_hour.get(source.id, 0)), "lines": rows,
"more_url": base + urlencode({**f.args(), "before": after, "group": last_group}) if after else None,
"f": f, "filtered": bool({k: v for k, v in f.args().items() if k != "range"}),
"severities": SEVERITY_CHOICES, "apps": apps, "ranges": list(RANGES)}
if before:
return await render_template("syslog/_lines_page.html", **ctx)
template = "syslog/_lines_table.html" if request.headers.get("HX-Request") else "syslog/source.html"
resp = await current_app.make_response(await render_template(template, **ctx))
resp.vary.add("HX-Request")
return resp
@syslog_bp.get("/panel")
@require_role(UserRole.viewer)
async def lines_panel():
"""The syslog panel on a host's page (?host_id=) or an SNMP device's
(?address=, and ?host_id= when it is bound): the newest lines of the
sources that map to it. Nothing when no added source does."""
async with current_app.db_sessionmaker() as db:
matched = await shown_on(db, request.args.get("host_id") or None, request.args.get("address") or None)
if not matched:
return ""
names = {s.id: source_name(s) for s in matched}
since = datetime.now(timezone.utc) - PANEL_SPAN
lines = (await db.execute(
select(SyslogLine).where(SyslogLine.source_id.in_(list(names)), SyslogLine.received_at >= since)
.order_by(SyslogLine.received_at.desc(), SyslogLine.id.desc()).limit(PANEL_LINES))).scalars().all()
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id} for ln in lines]
return await render_template("syslog/panel.html", lines=rows,
sources=[{"id": sid, "name": name} for sid, name in names.items()])
# ── Patterns: telling phrases counted for alert rules (#6001) ─────────────────
PATTERNS = "/plugins/syslog/patterns"
def pattern_view(p: SyslogPattern, sources: dict, latest: dict) -> dict:
covered = [s for s in sources.values() if s.state == "accepted" and p.source_id in (None, s.id)]
return {"id": p.id, "key": p.key, "name": p.name, "metric": p.metric, "phrases": p.phrases or [],
"severity": severity_name(p.max_severity) if p.max_severity is not None else None,
"scope": sources[p.source_id].name or sources[p.source_id].address
if p.source_id in sources else None,
"window": p.window_minutes, "enabled": p.enabled,
"counts": [{"source": resource_of(s), "value": _value(latest, resource_of(s), p.metric)} for s in covered],
"alert_resource": resource_of(covered[0]) if covered else None}
def _value(latest: dict, resource: str, metric: str) -> int | None:
row = latest.get(resource, {}).get(metric)
return int(row.value) if row is not None else None
@syslog_bp.get("/patterns")
@require_role(UserRole.viewer)
async def patterns_index():
async with current_app.db_sessionmaker() as db:
patterns = list((await db.execute(select(SyslogPattern).order_by(SyslogPattern.name))).scalars())
sources = {s.id: s for s in (await db.execute(select(SyslogSource))).scalars()}
latest = await latest_by_resource(db, SOURCE_MODULE, {resource_of(s) for s in sources.values()},
[p.metric for p in patterns])
taken = {p.key for p in patterns}
return await render_template("syslog/patterns.html", patterns=[pattern_view(p, sources, latest) for p in patterns],
presets=[p for p in PRESETS if p.key not in taken],
any_sources=any(s.state == "accepted" for s in sources.values()))
async def _free_key(db, wanted: str) -> str:
taken = set((await db.execute(select(SyslogPattern.key))).scalars())
key, n = wanted, 2
while key in taken:
key = f"{wanted[:44]}_{n}"
n += 1
return key
async def _accepted_sources(db) -> list:
return list((await db.execute(select(SyslogSource).where(SyslogSource.state == "accepted")
.order_by(SyslogSource.name))).scalars())
async def _render_pattern_form(pattern, values: dict, error: str | None = None, status: int = 200):
async with current_app.db_sessionmaker() as db:
sources = await _accepted_sources(db)
return await render_template("syslog/pattern_form.html", pattern=pattern, v=values, error=error,
sources=sources, windows=WINDOWS, severities=list(enumerate(SEVERITIES))), status
@syslog_bp.post("/patterns/preset/<key>")
@require_role(UserRole.admin)
async def add_preset(key: str):
preset = PRESETS_BY_KEY.get(key)
if preset is None:
abort(404)
async with current_app.db_sessionmaker() as db:
async with db.begin():
pattern = SyslogPattern(key=await _free_key(db, preset.key), name=preset.name,
phrases=list(preset.phrases), max_severity=preset.max_severity)
db.add(pattern)
await db.flush()
pattern_id = pattern.id
await log_audit(current_app, *_actor(), "syslog.pattern_added", entity_type="syslog_pattern",
entity_id=pattern_id, detail={"name": preset.name, "preset": preset.key})
return redirect(PATTERNS)
@syslog_bp.get("/patterns/new")
@require_role(UserRole.admin)
async def new_pattern():
return await _render_pattern_form(None, {"window_minutes": 15, "enabled": True})
def _typed_pattern(form) -> dict:
return {"name": form.get("name", ""), "phrases": form.get("phrases", ""),
"max_severity": form.get("max_severity", ""), "source_id": form.get("source_id", ""),
"window_minutes": form.get("window_minutes", 15, type=int), "enabled": "enabled" in form}
@syslog_bp.post("/patterns/new")
@require_role(UserRole.admin)
async def create_pattern():
form = await request.form
try:
values = parse_form(form)
except PatternError as exc:
return await _render_pattern_form(None, _typed_pattern(form), str(exc), 422)
async with current_app.db_sessionmaker() as db:
async with db.begin():
if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None:
values["source_id"] = None
pattern = SyslogPattern(key=await _free_key(db, key_for(values["name"])), **values)
db.add(pattern)
await db.flush()
pattern_id = pattern.id
await log_audit(current_app, *_actor(), "syslog.pattern_added", entity_type="syslog_pattern",
entity_id=pattern_id, detail={"name": values["name"]})
return redirect(PATTERNS)
async def _pattern_or_404(db, pattern_id: str) -> SyslogPattern:
pattern = await db.get(SyslogPattern, pattern_id)
if pattern is None:
abort(404)
return pattern
@syslog_bp.get("/patterns/<pattern_id>/edit")
@require_role(UserRole.admin)
async def edit_pattern(pattern_id: str):
async with current_app.db_sessionmaker() as db:
p = await _pattern_or_404(db, pattern_id)
return await _render_pattern_form(p, {
"name": p.name, "phrases": "\n".join(p.phrases or []),
"max_severity": "" if p.max_severity is None else str(p.max_severity),
"source_id": p.source_id or "", "window_minutes": p.window_minutes, "enabled": p.enabled})
@syslog_bp.post("/patterns/<pattern_id>/edit")
@require_role(UserRole.admin)
async def save_pattern(pattern_id: str):
"""Change what a pattern matches. Its key, and so its metric and the
alert rules on it, stay as they were."""
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
p = await _pattern_or_404(db, pattern_id)
try:
values = parse_form(form)
except PatternError as exc:
return await _render_pattern_form(p, _typed_pattern(form), str(exc), 422)
if values["source_id"] and await db.get(SyslogSource, values["source_id"]) is None:
values["source_id"] = None
for column, value in values.items():
setattr(p, column, value)
await log_audit(current_app, *_actor(), "syslog.pattern_updated", entity_type="syslog_pattern",
entity_id=pattern_id, detail={"name": values["name"]})
return redirect(PATTERNS)
@syslog_bp.post("/patterns/<pattern_id>/delete")
@require_role(UserRole.admin)
async def delete_pattern(pattern_id: str):
"""Remove a pattern. Its newest counts go with it, so nothing reads it as
a series that went quiet; its history ages out with retention."""
async with current_app.db_sessionmaker() as db:
async with db.begin():
p = await _pattern_or_404(db, pattern_id)
name, metric = p.name, p.metric
await db.delete(p)
await db.execute(delete(PluginMetricLatest).where(
PluginMetricLatest.source_module == SOURCE_MODULE, PluginMetricLatest.metric_name == metric))
await log_audit(current_app, *_actor(), "syslog.pattern_removed", entity_type="syslog_pattern",
entity_id=pattern_id, detail={"name": name})
return redirect(PATTERNS)
# ── Lines beside the readings (#6002) ─────────────────────────────────────────
AROUND = timedelta(minutes=30)
AROUND_SHOWN = 15
def _iso_arg(name: str) -> datetime | None:
value = request.args.get(name) or ""
try:
at = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return at if at.tzinfo else at.replace(tzinfo=timezone.utc)
@syslog_bp.get("/around")
@require_role(UserRole.viewer)
async def lines_around():
"""An anomaly's syslog: warnings and worse from the sources shown with
its resource (?source=&resource=), from half an hour before ?since= to
half an hour after ?until=, the lines nearest its start first."""
since = _iso_arg("since")
if since is None:
abort(400)
until = _iso_arg("until") or datetime.now(timezone.utc)
async with current_app.db_sessionmaker() as db:
host_id, address = await place_for(db, request.args.get("source", ""), request.args.get("resource", ""))
matched = await shown_on(db, host_id, address) if host_id or address else []
lines = await lines_between(db, matched, since - AROUND, until + AROUND, AROUND_SEVERITY, AROUND_LINES)
names = {s.id: source_name(s) for s in matched}
nearest = sorted(lines, key=lambda ln: abs((ln.received_at - since).total_seconds()))[:AROUND_SHOWN]
rows = [{**line_view(ln), "source": names[ln.source_id], "source_id": ln.source_id}
for ln in sorted(nearest, key=lambda ln: ln.received_at)]
return await render_template("syslog/_around.html", lines=rows, more=len(lines) - len(rows),
sources=[{"id": sid, "name": name} for sid, name in names.items()])
@syslog_bp.get("/marks")
@require_role(UserRole.viewer)
async def chart_marks():
"""Errors and worse from the sources a host's (?host_id=) or a device's
(?address=) page shows, over its chart's span (?range= or ?hours=), for
the chart to mark: {"marks": [{"at": epoch ms, "level", "text"}], "more"}."""
from steward.core.time_range import parse_range
now = datetime.now(timezone.utc)
hours = request.args.get("hours", type=int)
since = now - timedelta(hours=min(max(hours, 1), 24 * 30)) if hours else parse_range(request.args.get("range"))[0]
async with current_app.db_sessionmaker() as db:
matched = await shown_on(db, request.args.get("host_id") or None, request.args.get("address") or None)
lines = await lines_between(db, matched, since, now, MARK_SEVERITY, MAX_MARKS + 1)
names = {s.id: source_name(s) for s in matched}
return {"marks": [{"at": int(ln.received_at.timestamp() * 1000), "level": ln.severity,
"text": f"{names[ln.source_id]} {severity_name(ln.severity)}"
f"{' ' + ln.app if ln.app else ''}: {ln.message[:200]}"}
for ln in lines[:MAX_MARKS]],
"more": len(lines) > MAX_MARKS}