feat(syslog): set-up help on the page, port 5514 published, Docker-hidden senders called out (#6003)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m29s
CI / integration (push) Successful in 1m38s
CI / publish (push) Successful in 20s

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>
This commit is contained in:
2026-10-19 07:01:56 -04:00
co-authored by Claude Opus 5.5
parent 96f774ce24
commit ed9da56efa
10 changed files with 221 additions and 5 deletions
+60
View File
@@ -0,0 +1,60 @@
# plugins/syslog/network.py
"""Whether a sender's address is really the sender's (#6003).
A source is told apart by the address its lines come from. Docker can hide
that address: a port published through Swarm's ingress (the default) hands
every packet to Steward from an address on the ingress network, so every
device reads as one sender. Such an address lies on one of the container's
own networks. The page says so beside it and how to fix it: publish the
port in host mode."""
from __future__ import annotations
import ipaddress
import socket
import struct
from functools import lru_cache
ROUTES = "/proc/net/route"
def subnets_from(route_table: str) -> list[ipaddress.IPv4Network]:
"""The networks a host is directly on, from /proc/net/route's text: each
route with no gateway, the default route left out."""
out = []
for line in route_table.splitlines()[1:]:
fields = line.split()
if len(fields) < 8:
continue
try:
dest, gateway, mask = (int(fields[i], 16) for i in (1, 2, 7))
except ValueError:
continue
if dest == 0 or gateway != 0:
continue
address = socket.inet_ntoa(struct.pack("<I", dest))
netmask = socket.inet_ntoa(struct.pack("<I", mask))
try:
out.append(ipaddress.IPv4Network(f"{address}/{netmask}", strict=False))
except ValueError:
continue
return out
@lru_cache(maxsize=1)
def own_subnets() -> tuple[ipaddress.IPv4Network, ...]:
"""This container's own networks, read once; none off Linux."""
try:
with open(ROUTES) as f:
return tuple(subnets_from(f.read()))
except OSError:
return ()
def on_own_network(address: str, subnets=None) -> bool:
"""Whether a sender's address is on one of Steward's own networks: most
likely Docker standing in for the real sender."""
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False
return any(ip in net for net in (own_subnets() if subnets is None else subnets))
+3
View File
@@ -19,6 +19,7 @@ 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)
@@ -55,6 +56,7 @@ def listener_view(port: int, udp: bool, tcp: bool) -> dict:
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,
@@ -89,6 +91,7 @@ async def index():
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))
+34 -2
View File
@@ -34,6 +34,38 @@
{% 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>
{% set hidden_senders = (offered + sources)|selectattr("docker_address")|list %}
{% if hidden_senders %}
<p class="tone-warn" role="status">{{ hidden_senders|map(attribute="address")|unique|join(", ") }} {{ "is" if hidden_senders|map(attribute="address")|unique|list|length == 1 else "are" }} on Steward's own container network: Docker is most likely standing in for the real devices, so they all read as one sender. Publish the port in host mode, as <a href="#syslog-setup">Point a device here</a> shows.</p>
{% endif %}
<details class="card" id="syslog-setup"{% if not sources and not offered %} open{% endif %}>
<summary class="section-title" style="cursor:pointer;">Point a device here</summary>
{% set port = listener.port %}
{% set proto = listener.protocols|join(" or ") or "UDP or TCP" %}
<p style="margin:0.75rem 0 0.5rem;">In the device's remote syslog settings, send to <span class="mono">{{ server }}</span>, port <span class="mono">{{ port }}</span>, {{ proto }}. If the device can't resolve that name, use the server's IP address. Choose the RFC 5424 (IETF) format where it is offered: its lines carry their zone. Once a device sends, it appears above as sending, and Add keeps its lines.</p>
<table class="table" style="width:100%;font-size:0.82rem;">
<thead><tr><th>Device</th><th>Where</th></tr></thead>
<tbody>
<tr><td>Synology (DSM)</td><td>Log Center → Log Sending: send logs to a syslog server, with the server, port, protocol and the IETF format.</td></tr>
<tr><td>QNAP (QTS)</td><td>QuLog Center → Log Sender: add a destination with the server, port, protocol and RFC 5424.</td></tr>
<tr><td>TrueNAS</td><td>System → Advanced Settings → Syslog: the remote syslog server as <span class="mono">{{ server }}:{{ port }}</span>, and its transport.</td></tr>
<tr><td>OpenWrt</td><td>System → System → Logging: the external system log server, its port and protocol.</td></tr>
<tr><td>UniFi</td><td>Network settings, under remote logging (named Activity Logging or SIEM server in newer versions): the server and port.</td></tr>
<tr><td>Linux (rsyslog)</td><td><span class="mono">*.* @{{ server }}:{{ port }}</span> (UDP) or <span class="mono">*.* @@{{ server }}:{{ port }}</span> (TCP) in <span class="mono">/etc/rsyslog.d/90-steward.conf</span>, then restart rsyslog.</td></tr>
</tbody>
</table>
<h3 class="section-title" style="margin:1rem 0 0.4rem;font-size:0.9rem;">Nothing arriving?</h3>
<ul class="field-hint" style="margin:0;padding-left:1.1rem;">
<li>Steward's container must publish port {{ port }}. In Docker Swarm publish it in <strong>host mode</strong>: through Swarm's default ingress every device reaches Steward from one internal address and can't be told apart.</li>
<li>A firewall on the server, or between the device's network and the server's, may drop it.</li>
<li>Ports below 1024, such as syslog's usual 514, can't be opened by Steward. Map 514 to {{ port }} where the port is published, or point devices at {{ port }}.</li>
</ul>
<pre class="mono" style="margin:0.6rem 0 0;font-size:0.78rem;white-space:pre-wrap;">ports:
- { target: {{ port }}, published: {{ port }}, protocol: udp, mode: host }
- { target: {{ port }}, published: {{ port }}, protocol: tcp, mode: host }</pre>
</details>
{% if offered %}
<section class="card" aria-labelledby="syslog-offered">
<h2 id="syslog-offered" class="section-title">Sending, not added</h2>
@@ -43,7 +75,7 @@
<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><span class="mono">{{ s.address }}</span>{% if s.hostname_seen %}<div class="field-hint">calls itself {{ s.hostname_seen }}</div>{% endif %}{% if s.docker_address %}<div class="tone-warn" style="font-size:0.78rem;">Docker's address, not the device's</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>
@@ -82,7 +114,7 @@
<tbody>
{% for s in sources %}
<tr>
<td><a href="/plugins/syslog/sources/{{ s.id }}">{{ s.name }}</a><div class="field-hint mono">{{ s.address }}</div></td>
<td><a href="/plugins/syslog/sources/{{ s.id }}">{{ s.name }}</a><div class="field-hint mono">{{ s.address }}</div>{% if s.docker_address %}<div class="tone-warn" style="font-size:0.78rem;">Docker's address, not the device's</div>{% endif %}</td>
<td>{% if s.host %}<a href="/hosts/{{ s.host.id }}">{{ s.host.name }}</a>{% else %}—{% endif %}</td>
<td class="num">{{ "{:,}".format(s.rate or 0) }}</td>
<td>{{ s.last_seen_at|ago }}</td>