feat(syslog): a syslog receiver: UDP and TCP, senders offered before they're kept, each source's lines in a ring (#5999)
CI / lint (push) Failing after 2s
CI / unit (push) Failing after 1m27s
CI / integration (push) Failing after 1m35s
CI / publish (push) Skipped

- 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>
This commit is contained in:
2026-10-18 23:09:07 -04:00
co-authored by Claude Opus 5.5
parent a59d1a7709
commit 51892a02c0
19 changed files with 1339 additions and 1 deletions
+48
View File
@@ -0,0 +1,48 @@
{# plugins/syslog/templates/syslog/edit.html — a source's name and host, and
Remove, which deletes the lines kept from it (#5999). #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}Edit {{ label }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Syslog", "/plugins/syslog/"), (label, ""), ("Edit", "")]) }}{% endblock %}
{% block content %}
{{ page_header("Edit " ~ label) }}
<div class="form-page">
<form method="post" action="/plugins/syslog/sources/{{ source.id }}/edit">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="syslog-source">
<h2 id="syslog-source" class="section-title">Source</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="syslog-name">Name</label>
<input class="input" id="syslog-name" name="name" maxlength="100" value="{{ source.name }}" placeholder="{{ source.hostname_seen or source.address }}" autocomplete="off">
</div>
<div class="field">
<span class="field-label">Address</span>
<span class="mono">{{ source.address }}</span>
<span class="field-hint">Lines are matched to a source by the address they come from.</span>
</div>
<div class="field">
<label class="field-label" for="syslog-host">Show on host</label>
<select class="input" id="syslog-host" name="host_id">
<option value="">No host</option>
{% for h in hosts %}<option value="{{ h.id }}"{% if source.host_id == h.id %} selected{% endif %}>{{ h.name }}</option>{% endfor %}
</select>
<span class="field-hint">Which host's page shows this source's lines.</span>
</div>
</div>
</section>
<div class="page-actions">
<button type="submit" class="btn">Save</button>
<a href="/plugins/syslog/" class="btn btn-ghost">Cancel</a>
</div>
</form>
<section class="card form-section" aria-labelledby="syslog-remove" style="margin-top:1.5rem;">
<h2 id="syslog-remove" class="section-title">Remove</h2>
<p class="field-hint" style="margin:0 0 0.75rem;">Deletes the source and every line kept from it. If the device is still sending, it is offered again.</p>
<form method="post" action="/plugins/syslog/sources/{{ source.id }}/delete" data-confirm="Remove {{ label }} and the lines kept from it?" style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-danger btn-sm">Remove source</button>
</form>
</section>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{# plugins/syslog/templates/syslog/index.html — whether Steward is listening,
the sources whose lines are kept, and the devices sending that haven't been
added: counted, never stored, until an admin adds one (#5999). #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, empty_state %}
{% block title %}Syslog — Steward{% endblock %}
{% macro tone(level) -%}{% if level is not none and level <= 3 %}tone-crit{% elif level == 4 %}tone-warn{% endif %}{%- endmacro %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{{ page_header("Syslog", (sources|length ~ " source" ~ ("s" if sources|length != 1 else "")) if sources else none) }}
<section class="card" aria-labelledby="syslog-listener">
<div class="host-health-head">
{% if listener.error %}{{ status_badge('down', 'Not listening') }}
{% elif listener.listening %}{{ status_badge('up', 'Listening') }}
{% elif not listener.protocols %}{{ status_badge('inactive', 'Off') }}
{% else %}{{ status_badge('pending', 'Starting') }}{% endif %}
<h2 id="syslog-listener" class="section-title" style="margin:0;">Listener</h2>
{% if listener.protocols %}<span class="field-hint mono">{{ listener.protocols|join(" and ") }} port {{ listener.port }}</span>{% endif %}
{% if admin %}<span class="page-actions" style="margin-left:auto;"><a href="/settings/plugins/syslog/" class="btn btn-sm btn-ghost">Settings</a></span>{% endif %}
</div>
{% if listener.error %}
<p class="tone-crit" style="margin:0 0 0.4rem;">{{ listener.error }}</p>
<p class="field-hint" style="margin:0;">Tried again every minute. Another program may hold the port, or it is below 1024, which Steward can't open: choose another in Settings.</p>
{% elif not listener.protocols %}
<p class="field-hint" style="margin:0;">UDP and TCP are both off in Settings, so nothing is received.</p>
{% else %}
<p class="field-hint" style="margin:0;">
Point a device's remote syslog at this server, {{ listener.protocols|join(" or ") }} port {{ listener.port }}.
{%- if listener.bound_at %} Open since {{ listener.bound_at|ago }}: {{ "{:,}".format(listener.received) }} line{{ "s" if listener.received != 1 }} received{% if listener.connections %}, {{ listener.connections }} TCP sender{{ "s" if listener.connections != 1 }} connected{% endif %}.{% endif %}
</p>
{% endif %}
{% if listener.dropped %}<p class="tone-warn" style="margin:0.4rem 0 0;">{{ "{:,}".format(listener.dropped) }} line{{ "s" if listener.dropped != 1 }} lost: they arrived faster than they could be read.</p>{% endif %}
{% if listener.senders_full %}<p class="tone-warn" style="margin:0.4rem 0 0;">Steward knows {{ listener.max_senders }} senders, the most it keeps; a new one is counted, not offered{% if listener.unoffered %} ({{ "{:,}".format(listener.unoffered) }} line{{ "s" if listener.unoffered != 1 }} so far){% endif %}. Remove ones you don't need.</p>{% endif %}
</section>
{% if offered %}
<section class="card" aria-labelledby="syslog-offered">
<h2 id="syslog-offered" class="section-title">Sending, not added</h2>
<p class="field-hint" style="margin:0 0 0.75rem;">These devices are sending syslog. Their lines are counted and not kept until you add them.</p>
<table class="table" style="width:100%;font-size:0.82rem;">
<thead><tr><th>Sender</th><th>Lines</th><th>Last heard</th><th>Newest line</th>{% if admin %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for s in offered %}
<tr>
<td><span class="mono">{{ s.address }}</span>{% if s.hostname_seen %}<div class="field-hint">calls itself {{ s.hostname_seen }}</div>{% endif %}</td>
<td>{{ "{:,}".format(s.lines_total) }}</td>
<td>{{ s.last_seen_at|ago }}</td>
<td class="mono" style="max-width:28rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ s.last_line }}"><span class="{{ tone(s.level) }}">{{ s.severity or "" }}</span> {{ s.last_line or "—" }}</td>
{% if admin %}
<td style="white-space:nowrap;">
<form method="post" action="/plugins/syslog/sources/{{ s.id }}/add" style="display:inline-flex;gap:0.4rem;align-items:center;margin:0;">{{ csrf_field() }}
<input class="input" name="name" aria-label="Name for {{ s.address }}" maxlength="100" value="{{ s.hostname_seen or '' }}" placeholder="Name" style="width:9rem;">
<select class="input" name="host_id" aria-label="Host for {{ s.address }}" style="width:9rem;">
<option value="">No host</option>
{% for h in hosts %}<option value="{{ h.id }}"{% if s.suggested_host == h.id %} selected{% endif %}>{{ h.name }}</option>{% endfor %}
</select>
<button type="submit" class="btn btn-sm">Add</button>
</form>
<form method="post" action="/plugins/syslog/sources/{{ s.id }}/ignore" style="display:inline;margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">Ignore</button>
</form>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section class="card" aria-labelledby="syslog-sources">
<h2 id="syslog-sources" class="section-title">Sources</h2>
{% if not sources %}
{{ empty_state("No sources yet. Point a NAS, router or other device at this server's syslog port: it shows above as sending, and Add keeps its lines.") }}
{% else %}
<table class="table" style="width:100%;font-size:0.82rem;">
<thead><tr><th>Source</th><th>Host</th><th>Lines</th><th>Last heard</th><th>Newest line</th>{% if admin %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for s in sources %}
<tr>
<td>{{ s.name }}<div class="field-hint mono">{{ s.address }}</div></td>
<td>{% if s.host %}<a href="/hosts/{{ s.host.id }}">{{ s.host.name }}</a>{% else %}—{% endif %}</td>
<td>{{ "{:,}".format(s.lines_total) }}</td>
<td>{{ s.last_seen_at|ago }}</td>
<td class="mono" style="max-width:28rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ s.last_line }}"><span class="{{ tone(s.level) }}">{{ s.severity or "" }}</span> {{ s.last_line or "—" }}</td>
{% if admin %}<td><a href="/plugins/syslog/sources/{{ s.id }}/edit" class="btn btn-sm btn-ghost">Edit</a></td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<p class="field-hint" style="margin:0.6rem 0 0;">Lines counts every line since the device was first heard, added or not.</p>
{% endif %}
</section>
{% if ignored %}
<details class="card">
<summary class="section-title" style="cursor:pointer;">Ignored ({{ ignored|length }})</summary>
<table class="table" style="width:100%;font-size:0.82rem;margin-top:0.75rem;">
<thead><tr><th>Sender</th><th>Lines</th><th>Last heard</th>{% if admin %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for s in ignored %}
<tr>
<td><span class="mono">{{ s.address }}</span>{% if s.hostname_seen %}<div class="field-hint">calls itself {{ s.hostname_seen }}</div>{% endif %}</td>
<td>{{ "{:,}".format(s.lines_total) }}</td>
<td>{{ s.last_seen_at|ago }}</td>
{% if admin %}<td><form method="post" action="/plugins/syslog/sources/{{ s.id }}/offer" style="margin:0;">{{ csrf_field() }}<button type="submit" class="btn btn-sm btn-ghost">Offer again</button></form></td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% endblock %}