#!/usr/bin/env python3 """UDP syslog receiver for Turnstone cluster monitoring. Listens on UDP port 5140 (non-privileged) and appends received messages to /devl/turnstone-cluster/data/network-syslog.txt for the Turnstone live watcher to tail. Each line written is: This preserves the original syslog content while adding the sender IP so Turnstone's syslog ingestor can tag entries by device. Usage: python3 syslog_receiver.py [--port 5140] [--output /path/to/network-syslog.txt] Installed as: turnstone-syslog-receiver.service (see adjacent .service file) """ from __future__ import annotations import argparse import asyncio import logging import signal import sys from pathlib import Path logger = logging.getLogger("syslog-receiver") DEFAULT_PORT = 5140 DEFAULT_OUTPUT = "/devl/turnstone-cluster/data/network-syslog.txt" class SyslogReceiverProtocol(asyncio.DatagramProtocol): def __init__(self, output_path: Path) -> None: self._output_path = output_path self._fh = output_path.open("a", buffering=1) # line-buffered def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: try: message = data.decode("utf-8", errors="replace").rstrip("\r\n") except Exception: return if not message: return # RFC 3164 messages already include the sending hostname — write raw. try: self._fh.write(f"{message}\n") except OSError as exc: logger.error("Write failed: %s", exc) def error_received(self, exc: Exception) -> None: logger.warning("Socket error: %s", exc) def connection_lost(self, exc: Exception | None) -> None: self._fh.flush() self._fh.close() async def run(port: int, output_path: Path) -> None: output_path.parent.mkdir(parents=True, exist_ok=True) loop = asyncio.get_running_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: SyslogReceiverProtocol(output_path), local_addr=("0.0.0.0", port), ) logger.info("Listening on UDP :%d → %s", port, output_path) stop = loop.create_future() for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop.set_result, None) await stop transport.close() logger.info("Syslog receiver stopped.") def main() -> None: parser = argparse.ArgumentParser(description="UDP syslog receiver for Turnstone") parser.add_argument("--port", type=int, default=DEFAULT_PORT) parser.add_argument("--output", default=DEFAULT_OUTPUT) args = parser.parse_args() logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout, ) asyncio.run(run(args.port, Path(args.output))) if __name__ == "__main__": main()