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>
This commit is contained in:
@@ -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))
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user