feat(syslog): devices through Swarm's ingress told apart by name; the send-to address is a setting (#6096)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m30s
CI / integration (push) Successful in 1m38s
CI / publish (push) Skipped

Through ingress every device arrives from Docker's own address, so a
source there is keyed by the hostname its lines carry (new column
sender, unique with address). The set-up help no longer guesses the
address from the web hostname: it shows the plugin's send_to setting,
covers ingress and host mode, ASUSTOR ADM, and both syslog formats.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 12:05:28 -04:00
co-authored by Claude Opus 5.5
parent d2926b6663
commit 0a647bbb82
11 changed files with 193 additions and 53 deletions
+32 -12
View File
@@ -27,6 +27,7 @@ from sqlalchemy import func, select
from steward.core.scheduler import ScheduledTask
from .network import on_own_network
from .parse import MAX_MESSAGE, Message, frames, parse
if TYPE_CHECKING:
@@ -128,6 +129,14 @@ def settings(app: "Quart") -> tuple[int, bool, bool]:
return int(cfg.get("port", 5514)), bool(cfg.get("udp", True)), bool(cfg.get("tcp", True))
def send_to(app: "Quart") -> str:
"""The address the set-up help tells devices to send to, as set in the
plugin's settings: the web address Steward is reached by is often a name
only its proxy answers, so it isn't guessed from that."""
cfg = (app.config.get("PLUGINS") or {}).get("syslog") or {}
return str(cfg.get("send_to") or "").strip()
async def close() -> None:
if LISTENER._udp_transport is not None:
LISTENER._udp_transport.close()
@@ -218,32 +227,43 @@ async def _store(app: "Quart", rounds: list[list[Received]]) -> None:
await store_lines(session, [line for lines in rounds for line in lines])
def sender_of(line: Received) -> tuple[str, str]:
"""(address, sender) a line is filed under. A line from Docker's own
address (a port published on Swarm's ingress) is any device's, so the
name it carries tells the device; elsewhere the address alone does, and
a device renamed stays one source."""
if on_own_network(line.address):
return line.address, line.message.hostname[:255]
return line.address, ""
async def store_lines(session, lines: list[Received]) -> None:
from .models import SyslogLine, SyslogSource
if not lines:
return
by_address: dict[str, list[Received]] = {}
by_sender: dict[tuple[str, str], list[Received]] = {}
for line in lines:
by_address.setdefault(line.address, []).append(line)
sources = {s.address: s for s in (await session.execute(
select(SyslogSource).where(SyslogSource.address.in_(list(by_address))))).scalars()}
new = [a for a in by_address if a not in sources]
by_sender.setdefault(sender_of(line), []).append(line)
addresses = list({address for address, _ in by_sender})
sources = {(s.address, s.sender): s for s in (await session.execute(
select(SyslogSource).where(SyslogSource.address.in_(addresses)))).scalars()}
new = [k for k in by_sender if k not in sources]
if new:
known = (await session.execute(select(func.count()).select_from(SyslogSource))).scalar_one()
for address in new:
for key in new:
if known >= MAX_SENDERS:
LISTENER.unoffered += len(by_address.pop(address))
LISTENER.unoffered += len(by_sender.pop(key))
continue
first = by_address[address][0]
source = SyslogSource(address=address, first_seen_at=first.at, last_seen_at=first.at)
first = by_sender[key][0]
source = SyslogSource(address=key[0], sender=key[1], first_seen_at=first.at, last_seen_at=first.at)
session.add(source)
sources[address] = source
sources[key] = source
known += 1
await session.flush()
for address, received in by_address.items():
source = sources[address]
for key, received in by_sender.items():
source = sources[key]
newest = received[-1]
source.lines_total = (source.lines_total or 0) + len(received)
source.last_seen_at = max(source.last_seen_at, newest.at) if source.last_seen_at else newest.at