# 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, send_to, 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, "sender": s.sender, "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; for a device that reaches Steward through Docker's address, the host of the name it gives.""" if source.sender: name = source.sender.lower() return next((h.id for h in hosts if name in (h.name.lower(), (h.address or "").lower())), None) 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=send_to(current_app), 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//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//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_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//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/") @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= 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/") @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//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//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//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}