From 55896b0caa0f61a5230d2a806722869c0622dae3 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 4 Apr 2026 04:31:00 -0300 Subject: [PATCH] Add RFC 5424 syslog logging to all service templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decnet/logging/syslog_formatter.py: RFC 5424 formatter (local0 facility, decnet@55555 SD element ID, full escaping per §6.3.3) - decnet/logging/file_handler.py: rotating file handler (10 MB / 5 backups), path configurable via DECNET_LOG_FILE env var - templates/decnet_logging.py: combined syslog_line / write_syslog_file / forward_syslog helper distributed to all 22 service template dirs - All templates/*/server.py: replaced ad-hoc JSON _forward/_log with RFC 5424 syslog_line + write_syslog_file + forward_syslog - All templates/*/Dockerfile: COPY decnet_logging.py /opt/ - DecnetConfig: added log_file field; CLI: --log-file flag; composer injects DECNET_LOG_FILE env var into service containers - tests/test_syslog_formatter.py + tests/test_file_handler.py: 25 new tests Co-Authored-By: Claude Sonnet 4.6 --- decnet/cli.py | 4 + decnet/composer.py | 2 + decnet/config.py | 1 + decnet/logging/file_handler.py | 57 +++++++++ decnet/logging/syslog_formatter.py | 84 +++++++++++++ templates/decnet_logging.py | 142 ++++++++++++++++++++++ templates/docker_api/Dockerfile | 1 + templates/docker_api/decnet_logging.py | 142 ++++++++++++++++++++++ templates/docker_api/server.py | 26 ++-- templates/elasticsearch/Dockerfile | 1 + templates/elasticsearch/decnet_logging.py | 142 ++++++++++++++++++++++ templates/elasticsearch/server.py | 26 ++-- templates/ftp/Dockerfile | 1 + templates/ftp/decnet_logging.py | 142 ++++++++++++++++++++++ templates/ftp/server.py | 26 ++-- templates/http/Dockerfile | 1 + templates/http/decnet_logging.py | 142 ++++++++++++++++++++++ templates/http/server.py | 26 ++-- templates/imap/Dockerfile | 1 + templates/imap/decnet_logging.py | 142 ++++++++++++++++++++++ templates/imap/server.py | 26 ++-- templates/k8s/Dockerfile | 3 +- templates/k8s/decnet_logging.py | 142 ++++++++++++++++++++++ templates/k8s/entrypoint.sh | 2 +- templates/k8s/server.py | 26 ++-- templates/ldap/Dockerfile | 1 + templates/ldap/decnet_logging.py | 142 ++++++++++++++++++++++ templates/ldap/server.py | 26 ++-- templates/llmnr/Dockerfile | 1 + templates/llmnr/decnet_logging.py | 142 ++++++++++++++++++++++ templates/llmnr/server.py | 26 ++-- templates/mongodb/Dockerfile | 1 + templates/mongodb/decnet_logging.py | 142 ++++++++++++++++++++++ templates/mongodb/server.py | 26 ++-- templates/mqtt/Dockerfile | 1 + templates/mqtt/decnet_logging.py | 142 ++++++++++++++++++++++ templates/mqtt/server.py | 26 ++-- templates/mssql/Dockerfile | 1 + templates/mssql/decnet_logging.py | 142 ++++++++++++++++++++++ templates/mssql/server.py | 26 ++-- templates/mysql/Dockerfile | 1 + templates/mysql/decnet_logging.py | 142 ++++++++++++++++++++++ templates/mysql/server.py | 26 ++-- templates/pop3/Dockerfile | 3 +- templates/pop3/decnet_logging.py | 142 ++++++++++++++++++++++ templates/pop3/entrypoint.sh | 2 +- templates/pop3/server.py | 26 ++-- templates/postgres/Dockerfile | 1 + templates/postgres/decnet_logging.py | 142 ++++++++++++++++++++++ templates/postgres/server.py | 26 ++-- templates/rdp/Dockerfile | 1 + templates/rdp/decnet_logging.py | 142 ++++++++++++++++++++++ templates/rdp/server.py | 26 ++-- templates/redis/Dockerfile | 1 + templates/redis/decnet_logging.py | 142 ++++++++++++++++++++++ templates/redis/server.py | 26 ++-- templates/sip/Dockerfile | 1 + templates/sip/decnet_logging.py | 142 ++++++++++++++++++++++ templates/sip/server.py | 26 ++-- templates/smb/Dockerfile | 1 + templates/smb/decnet_logging.py | 142 ++++++++++++++++++++++ templates/smb/server.py | 26 ++-- templates/smtp/Dockerfile | 1 + templates/smtp/decnet_logging.py | 142 ++++++++++++++++++++++ templates/smtp/server.py | 26 ++-- templates/snmp/Dockerfile | 1 + templates/snmp/decnet_logging.py | 142 ++++++++++++++++++++++ templates/snmp/server.py | 26 ++-- templates/tftp/Dockerfile | 1 + templates/tftp/decnet_logging.py | 142 ++++++++++++++++++++++ templates/tftp/server.py | 26 ++-- templates/vnc/Dockerfile | 1 + templates/vnc/decnet_logging.py | 142 ++++++++++++++++++++++ templates/vnc/server.py | 26 ++-- tests/test_file_handler.py | 71 +++++++++++ tests/test_syslog_formatter.py | 135 ++++++++++++++++++++ 76 files changed, 3800 insertions(+), 422 deletions(-) create mode 100644 decnet/logging/file_handler.py create mode 100644 decnet/logging/syslog_formatter.py create mode 100644 templates/decnet_logging.py create mode 100644 templates/docker_api/decnet_logging.py create mode 100644 templates/elasticsearch/decnet_logging.py create mode 100644 templates/ftp/decnet_logging.py create mode 100644 templates/http/decnet_logging.py create mode 100644 templates/imap/decnet_logging.py create mode 100644 templates/k8s/decnet_logging.py create mode 100644 templates/ldap/decnet_logging.py create mode 100644 templates/llmnr/decnet_logging.py create mode 100644 templates/mongodb/decnet_logging.py create mode 100644 templates/mqtt/decnet_logging.py create mode 100644 templates/mssql/decnet_logging.py create mode 100644 templates/mysql/decnet_logging.py create mode 100644 templates/pop3/decnet_logging.py create mode 100644 templates/postgres/decnet_logging.py create mode 100644 templates/rdp/decnet_logging.py create mode 100644 templates/redis/decnet_logging.py create mode 100644 templates/sip/decnet_logging.py create mode 100644 templates/smb/decnet_logging.py create mode 100644 templates/smtp/decnet_logging.py create mode 100644 templates/snmp/decnet_logging.py create mode 100644 templates/tftp/decnet_logging.py create mode 100644 templates/vnc/decnet_logging.py create mode 100644 tests/test_file_handler.py create mode 100644 tests/test_syslog_formatter.py diff --git a/decnet/cli.py b/decnet/cli.py index 373a75f..5f6845a 100644 --- a/decnet/cli.py +++ b/decnet/cli.py @@ -185,6 +185,7 @@ def deploy( distro: Optional[str] = typer.Option(None, "--distro", help="Comma-separated distro slugs, e.g. debian,ubuntu22,rocky9"), randomize_distros: bool = typer.Option(False, "--randomize-distros", help="Assign a random distro to each decky"), log_target: Optional[str] = typer.Option(None, "--log-target", help="Forward logs to ip:port (e.g. 192.168.1.5:5140)"), + log_file: Optional[str] = typer.Option(None, "--log-file", help="Write RFC 5424 syslog to this path inside containers (e.g. /var/log/decnet/decnet.log)"), dry_run: bool = typer.Option(False, "--dry-run", help="Generate compose file without starting containers"), no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild all images, ignoring Docker layer cache"), config_file: Optional[str] = typer.Option(None, "--config", "-c", help="Path to INI config file"), @@ -233,6 +234,7 @@ def deploy( ) effective_log_target = log_target or ini.log_target + effective_log_file = log_file decky_configs = _build_deckies_from_ini( ini, subnet_cidr, effective_gateway, host_ip, randomize_services ) @@ -282,6 +284,7 @@ def deploy( distros_explicit=distros_list, randomize_distros=randomize_distros, ) effective_log_target = log_target + effective_log_file = log_file config = DecnetConfig( mode=mode, @@ -290,6 +293,7 @@ def deploy( gateway=effective_gateway, deckies=decky_configs, log_target=effective_log_target, + log_file=effective_log_file, ) if effective_log_target and not dry_run: diff --git a/decnet/composer.py b/decnet/composer.py index 280b630..3208c70 100644 --- a/decnet/composer.py +++ b/decnet/composer.py @@ -58,6 +58,8 @@ def generate_compose(config: DecnetConfig) -> dict: fragment.setdefault("environment", {}) fragment["environment"]["HOSTNAME"] = decky.hostname + if config.log_file: + fragment["environment"]["DECNET_LOG_FILE"] = config.log_file # Share the base container's network — no own IP needed fragment["network_mode"] = f"service:{base_key}" diff --git a/decnet/config.py b/decnet/config.py index 44bdfef..dbeb4f6 100644 --- a/decnet/config.py +++ b/decnet/config.py @@ -43,6 +43,7 @@ class DecnetConfig(BaseModel): gateway: str deckies: list[DeckyConfig] log_target: str | None = None # "ip:port" or None + log_file: str | None = None # path for RFC 5424 syslog file output @field_validator("log_target") @classmethod diff --git a/decnet/logging/file_handler.py b/decnet/logging/file_handler.py new file mode 100644 index 0000000..06d877a --- /dev/null +++ b/decnet/logging/file_handler.py @@ -0,0 +1,57 @@ +""" +Rotating file handler for DECNET syslog output. + +Writes RFC 5424 syslog lines to a local file. +Path is controlled by the DECNET_LOG_FILE environment variable +(default: /var/log/decnet/decnet.log). +""" + +import logging +import logging.handlers +import os +from pathlib import Path + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +_handler: logging.handlers.RotatingFileHandler | None = None +_logger: logging.Logger | None = None + + +def _get_logger() -> logging.Logger: + global _handler, _logger + if _logger is not None: + return _logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + log_path.parent.mkdir(parents=True, exist_ok=True) + + _handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + _handler.setFormatter(logging.Formatter("%(message)s")) + + _logger = logging.getLogger("decnet.syslog") + _logger.setLevel(logging.DEBUG) + _logger.propagate = False + _logger.addHandler(_handler) + + return _logger + + +def write_syslog(line: str) -> None: + """Write a single RFC 5424 syslog line to the rotating log file.""" + try: + _get_logger().info(line) + except Exception: + pass + + +def get_log_path() -> Path: + """Return the configured log file path (for tests/inspection).""" + return Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) diff --git a/decnet/logging/syslog_formatter.py b/decnet/logging/syslog_formatter.py new file mode 100644 index 0000000..b2b6e37 --- /dev/null +++ b/decnet/logging/syslog_formatter.py @@ -0,0 +1,84 @@ +""" +RFC 5424 syslog formatter for DECNET. + +Produces fully-compliant syslog messages: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16) +PEN for structured data: decnet@55555 +""" + +from datetime import datetime, timezone +from typing import Any + + +FACILITY_LOCAL0 = 16 +NILVALUE = "-" +_SD_ID = "decnet@55555" + +SEVERITY_INFO = 6 +SEVERITY_WARNING = 4 +SEVERITY_ERROR = 3 + +# RFC 5424 field length limits +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + + +def _pri(severity: int) -> str: + return f"<{FACILITY_LOCAL0 * 8 + severity}>" + + +def _truncate(value: str, maxlen: int) -> str: + return value[:maxlen] if len(value) > maxlen else value + + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return NILVALUE + params = " ".join( + f'{k}="{_sd_escape(str(v))}"' + for k, v in fields.items() + ) + return f"[{_SD_ID} {params}]" + + +def format_rfc5424( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME field (e.g. "http", "mysql") + hostname: HOSTNAME field (the decky node name) + event_type: MSGID field (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: Datetime to use; defaults to utcnow + msg: Optional free-text MSG suffix + **fields: Arbitrary key=value pairs encoded in structured data + """ + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + + pri = _pri(severity) + version = "1" + host = _truncate(hostname or NILVALUE, _MAX_HOSTNAME) + appname = _truncate(service or NILVALUE, _MAX_APPNAME) + procid = NILVALUE + msgid = _truncate(event_type or NILVALUE, _MAX_MSGID) + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + + return f"{pri}{version} {ts} {host} {appname} {procid} {msgid} {sd}{message}" diff --git a/templates/decnet_logging.py b/templates/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/docker_api/Dockerfile b/templates/docker_api/Dockerfile index b0001ed..c79b4f8 100644 --- a/templates/docker_api/Dockerfile +++ b/templates/docker_api/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir flask +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/docker_api/decnet_logging.py b/templates/docker_api/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/docker_api/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/docker_api/server.py b/templates/docker_api/server.py index 64e5b36..a8563ba 100644 --- a/templates/docker_api/server.py +++ b/templates/docker_api/server.py @@ -12,8 +12,10 @@ import socket from datetime import datetime, timezone from flask import Flask, request +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "docker-host") +SERVICE_NAME = "docker_api" LOG_TARGET = os.environ.get("LOG_TARGET", "") app = Flask(__name__) @@ -58,27 +60,13 @@ _CONTAINERS = [ ] -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "docker_api", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) @app.before_request diff --git a/templates/elasticsearch/Dockerfile b/templates/elasticsearch/Dockerfile index f8c066c..4b99892 100644 --- a/templates/elasticsearch/Dockerfile +++ b/templates/elasticsearch/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/elasticsearch/decnet_logging.py b/templates/elasticsearch/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/elasticsearch/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/elasticsearch/server.py b/templates/elasticsearch/server.py index 47cc620..4bacf3c 100644 --- a/templates/elasticsearch/server.py +++ b/templates/elasticsearch/server.py @@ -10,8 +10,10 @@ import os import socket from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, HTTPServer +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "esserver") +SERVICE_NAME = "elasticsearch" LOG_TARGET = os.environ.get("LOG_TARGET", "") _CLUSTER_UUID = "xC3Pr9abTq2mNkOeLvXwYA" @@ -36,27 +38,13 @@ _ROOT_RESPONSE = { } -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "elasticsearch", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class ESHandler(BaseHTTPRequestHandler): diff --git a/templates/ftp/Dockerfile b/templates/ftp/Dockerfile index d1ccd1c..58cef2c 100644 --- a/templates/ftp/Dockerfile +++ b/templates/ftp/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir twisted jinja2 +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/ftp/decnet_logging.py b/templates/ftp/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/ftp/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/ftp/server.py b/templates/ftp/server.py index 2c4dd34..4ec60c3 100644 --- a/templates/ftp/server.py +++ b/templates/ftp/server.py @@ -14,32 +14,20 @@ from datetime import datetime, timezone from twisted.internet import defer, protocol, reactor from twisted.protocols.ftp import FTP, FTPFactory from twisted.python import log as twisted_log +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "ftpserver") +SERVICE_NAME = "ftp" LOG_TARGET = os.environ.get("LOG_TARGET", "") -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "ftp", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class ServerFTP(FTP): diff --git a/templates/http/Dockerfile b/templates/http/Dockerfile index 29c2631..bb8c3b5 100644 --- a/templates/http/Dockerfile +++ b/templates/http/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir flask jinja2 +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/http/decnet_logging.py b/templates/http/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/http/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/http/server.py b/templates/http/server.py index 2df155f..ced2d36 100644 --- a/templates/http/server.py +++ b/templates/http/server.py @@ -12,8 +12,10 @@ from datetime import datetime, timezone from pathlib import Path from flask import Flask, request, send_from_directory +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "webserver") +SERVICE_NAME = "http" LOG_TARGET = os.environ.get("LOG_TARGET", "") SERVER_HEADER = os.environ.get("SERVER_HEADER", "Apache/2.4.54 (Debian)") RESPONSE_CODE = int(os.environ.get("RESPONSE_CODE", "403")) @@ -57,27 +59,13 @@ _FAKE_APP_BODIES: dict[str, str] = { app = Flask(__name__) -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "http", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) @app.before_request diff --git a/templates/imap/Dockerfile b/templates/imap/Dockerfile index 78e99dc..6738dcf 100644 --- a/templates/imap/Dockerfile +++ b/templates/imap/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/imap/decnet_logging.py b/templates/imap/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/imap/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/imap/server.py b/templates/imap/server.py index 01948bc..3eecd2d 100644 --- a/templates/imap/server.py +++ b/templates/imap/server.py @@ -10,33 +10,21 @@ import json import os import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mailserver") +SERVICE_NAME = "imap" LOG_TARGET = os.environ.get("LOG_TARGET", "") BANNER = f"* OK [{NODE_NAME}] IMAP4rev1 Service Ready\r\n" -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "imap", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class IMAPProtocol(asyncio.Protocol): diff --git a/templates/k8s/Dockerfile b/templates/k8s/Dockerfile index 2756caa..0b08592 100644 --- a/templates/k8s/Dockerfile +++ b/templates/k8s/Dockerfile @@ -8,7 +8,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir flask -COPY k8s_honeypot.py /opt/k8s_honeypot.py +COPY decnet_logging.py /opt/decnet_logging.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/k8s/decnet_logging.py b/templates/k8s/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/k8s/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/k8s/entrypoint.sh b/templates/k8s/entrypoint.sh index 2de327b..c830b73 100644 --- a/templates/k8s/entrypoint.sh +++ b/templates/k8s/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/k8s_honeypot.py +exec python3 /opt/server.py diff --git a/templates/k8s/server.py b/templates/k8s/server.py index 85ee0fa..5556583 100644 --- a/templates/k8s/server.py +++ b/templates/k8s/server.py @@ -12,8 +12,10 @@ import socket from datetime import datetime, timezone from flask import Flask, request +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "k8s-master") +SERVICE_NAME = "k8s" LOG_TARGET = os.environ.get("LOG_TARGET", "") app = Flask(__name__) @@ -65,27 +67,13 @@ _SECRETS = { } -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "k8s", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) @app.before_request diff --git a/templates/ldap/Dockerfile b/templates/ldap/Dockerfile index e71998a..0aa4f99 100644 --- a/templates/ldap/Dockerfile +++ b/templates/ldap/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/ldap/decnet_logging.py b/templates/ldap/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/ldap/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/ldap/server.py b/templates/ldap/server.py index 9999689..d126873 100644 --- a/templates/ldap/server.py +++ b/templates/ldap/server.py @@ -10,32 +10,20 @@ import json import os import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "ldapserver") +SERVICE_NAME = "ldap" LOG_TARGET = os.environ.get("LOG_TARGET", "") -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "ldap", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _ber_length(data: bytes, pos: int): diff --git a/templates/llmnr/Dockerfile b/templates/llmnr/Dockerfile index bb11ed6..5141b4a 100644 --- a/templates/llmnr/Dockerfile +++ b/templates/llmnr/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/llmnr/decnet_logging.py b/templates/llmnr/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/llmnr/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/llmnr/server.py b/templates/llmnr/server.py index 8a89480..6153e5e 100644 --- a/templates/llmnr/server.py +++ b/templates/llmnr/server.py @@ -12,32 +12,20 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "lan-host") +SERVICE_NAME = "llmnr" LOG_TARGET = os.environ.get("LOG_TARGET", "") -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "llmnr", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _decode_dns_name(data: bytes, offset: int) -> str: diff --git a/templates/mongodb/Dockerfile b/templates/mongodb/Dockerfile index b54cd3e..f0d3a90 100644 --- a/templates/mongodb/Dockerfile +++ b/templates/mongodb/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mongodb/decnet_logging.py b/templates/mongodb/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/mongodb/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/mongodb/server.py b/templates/mongodb/server.py index 2597f1a..600cda0 100644 --- a/templates/mongodb/server.py +++ b/templates/mongodb/server.py @@ -12,8 +12,10 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mongodb") +SERVICE_NAME = "mongodb" LOG_TARGET = os.environ.get("LOG_TARGET", "") # Minimal BSON helpers @@ -49,27 +51,13 @@ def _op_reply(request_id: int, doc: bytes) -> bytes: return header + doc -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "mongodb", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class MongoDBProtocol(asyncio.Protocol): diff --git a/templates/mqtt/Dockerfile b/templates/mqtt/Dockerfile index aff41ad..9435934 100644 --- a/templates/mqtt/Dockerfile +++ b/templates/mqtt/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mqtt/decnet_logging.py b/templates/mqtt/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/mqtt/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/mqtt/server.py b/templates/mqtt/server.py index aa40d70..61a929f 100644 --- a/templates/mqtt/server.py +++ b/templates/mqtt/server.py @@ -12,35 +12,23 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mqtt-broker") +SERVICE_NAME = "mqtt" LOG_TARGET = os.environ.get("LOG_TARGET", "") # CONNACK: packet type 0x20, remaining length 2, session_present=0, return_code=5 _CONNACK_NOT_AUTH = b"\x20\x02\x00\x05" -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "mqtt", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _read_utf8(data: bytes, pos: int): diff --git a/templates/mssql/Dockerfile b/templates/mssql/Dockerfile index a1ad71d..3f7346e 100644 --- a/templates/mssql/Dockerfile +++ b/templates/mssql/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mssql/decnet_logging.py b/templates/mssql/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/mssql/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/mssql/server.py b/templates/mssql/server.py index 80e8f82..ce9508e 100644 --- a/templates/mssql/server.py +++ b/templates/mssql/server.py @@ -11,8 +11,10 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "dbserver") +SERVICE_NAME = "mssql" LOG_TARGET = os.environ.get("LOG_TARGET", "") # Minimal TDS pre-login response @@ -39,27 +41,13 @@ _PRELOGIN_RESP = bytes([ ]) -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "mssql", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _tds_error_packet(message: str) -> bytes: diff --git a/templates/mysql/Dockerfile b/templates/mysql/Dockerfile index 730a74a..b765e3a 100644 --- a/templates/mysql/Dockerfile +++ b/templates/mysql/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/mysql/decnet_logging.py b/templates/mysql/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/mysql/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/mysql/server.py b/templates/mysql/server.py index 796bb5d..675f576 100644 --- a/templates/mysql/server.py +++ b/templates/mysql/server.py @@ -12,8 +12,10 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "dbserver") +SERVICE_NAME = "mysql" LOG_TARGET = os.environ.get("LOG_TARGET", "") _MYSQL_VER = os.environ.get("MYSQL_VERSION", "5.7.38-log") @@ -40,27 +42,13 @@ def _make_packet(payload: bytes, seq: int = 0) -> bytes: return struct.pack(" None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "mysql", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class MySQLProtocol(asyncio.Protocol): diff --git a/templates/pop3/Dockerfile b/templates/pop3/Dockerfile index 4ed4860..4ba305e 100644 --- a/templates/pop3/Dockerfile +++ b/templates/pop3/Dockerfile @@ -5,7 +5,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -COPY pop3_honeypot.py /opt/pop3_honeypot.py +COPY decnet_logging.py /opt/decnet_logging.py +COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/pop3/decnet_logging.py b/templates/pop3/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/pop3/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/pop3/entrypoint.sh b/templates/pop3/entrypoint.sh index fdf8548..c830b73 100644 --- a/templates/pop3/entrypoint.sh +++ b/templates/pop3/entrypoint.sh @@ -1,3 +1,3 @@ #!/bin/bash set -e -exec python3 /opt/pop3_honeypot.py +exec python3 /opt/server.py diff --git a/templates/pop3/server.py b/templates/pop3/server.py index 26ada84..7dc3ed7 100644 --- a/templates/pop3/server.py +++ b/templates/pop3/server.py @@ -11,33 +11,21 @@ import json import os import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mailserver") +SERVICE_NAME = "pop3" LOG_TARGET = os.environ.get("LOG_TARGET", "") BANNER = f"+OK {NODE_NAME} POP3 server ready\r\n" -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "pop3", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class POP3Protocol(asyncio.Protocol): diff --git a/templates/postgres/Dockerfile b/templates/postgres/Dockerfile index 1e46a82..bb42e19 100644 --- a/templates/postgres/Dockerfile +++ b/templates/postgres/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/postgres/decnet_logging.py b/templates/postgres/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/postgres/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/postgres/server.py b/templates/postgres/server.py index 3fb1611..a0e41e4 100644 --- a/templates/postgres/server.py +++ b/templates/postgres/server.py @@ -12,8 +12,10 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "pgserver") +SERVICE_NAME = "postgres" LOG_TARGET = os.environ.get("LOG_TARGET", "") SALT = b"\xde\xad\xbe\xef" @@ -25,27 +27,13 @@ def _error_response(message: str) -> bytes: return b"E" + struct.pack(">I", len(body) + 4) + body -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "postgres", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class PostgresProtocol(asyncio.Protocol): diff --git a/templates/rdp/Dockerfile b/templates/rdp/Dockerfile index df2517d..e1fe872 100644 --- a/templates/rdp/Dockerfile +++ b/templates/rdp/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir twisted jinja2 +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/rdp/decnet_logging.py b/templates/rdp/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/rdp/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/rdp/server.py b/templates/rdp/server.py index 350042f..f5c9fed 100644 --- a/templates/rdp/server.py +++ b/templates/rdp/server.py @@ -14,32 +14,20 @@ from datetime import datetime, timezone from twisted.internet import protocol, reactor from twisted.python import log as twisted_log +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION") +SERVICE_NAME = "rdp" LOG_TARGET = os.environ.get("LOG_TARGET", "") -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "rdp", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class RDPServerProtocol(protocol.Protocol): diff --git a/templates/redis/Dockerfile b/templates/redis/Dockerfile index cbf88f0..adae4ac 100644 --- a/templates/redis/Dockerfile +++ b/templates/redis/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/redis/decnet_logging.py b/templates/redis/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/redis/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/redis/server.py b/templates/redis/server.py index 6e5584a..02d7044 100644 --- a/templates/redis/server.py +++ b/templates/redis/server.py @@ -10,8 +10,10 @@ import json import os import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "cache-server") +SERVICE_NAME = "redis" LOG_TARGET = os.environ.get("LOG_TARGET", "") _REDIS_VER = os.environ.get("REDIS_VERSION", "7.0.12") _REDIS_OS = os.environ.get("REDIS_OS", "Linux 5.15.0") @@ -29,27 +31,13 @@ _INFO = ( ).encode() -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "redis", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _bulk(s: str) -> bytes: diff --git a/templates/sip/Dockerfile b/templates/sip/Dockerfile index cd0d02c..71abd02 100644 --- a/templates/sip/Dockerfile +++ b/templates/sip/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/sip/decnet_logging.py b/templates/sip/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/sip/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/sip/server.py b/templates/sip/server.py index 3b04059..d3e9128 100644 --- a/templates/sip/server.py +++ b/templates/sip/server.py @@ -11,8 +11,10 @@ import os import re import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "pbx") +SERVICE_NAME = "sip" LOG_TARGET = os.environ.get("LOG_TARGET", "") _401 = ( @@ -27,27 +29,13 @@ _401 = ( ) -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "sip", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _parse_headers(msg: str) -> dict: diff --git a/templates/smb/Dockerfile b/templates/smb/Dockerfile index 5d98fe6..7dab8b4 100644 --- a/templates/smb/Dockerfile +++ b/templates/smb/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN pip3 install --no-cache-dir impacket jinja2 +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/smb/decnet_logging.py b/templates/smb/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/smb/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/smb/server.py b/templates/smb/server.py index 7fdbef2..195cdcd 100644 --- a/templates/smb/server.py +++ b/templates/smb/server.py @@ -10,32 +10,20 @@ import socket from datetime import datetime, timezone from impacket import smbserver +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "WORKSTATION") +SERVICE_NAME = "smb" LOG_TARGET = os.environ.get("LOG_TARGET", "") -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "smb", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) if __name__ == "__main__": diff --git a/templates/smtp/Dockerfile b/templates/smtp/Dockerfile index 581520f..2098c1c 100644 --- a/templates/smtp/Dockerfile +++ b/templates/smtp/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/smtp/decnet_logging.py b/templates/smtp/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/smtp/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/smtp/server.py b/templates/smtp/server.py index ac2659c..be29df2 100644 --- a/templates/smtp/server.py +++ b/templates/smtp/server.py @@ -9,34 +9,22 @@ import json import os import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "mailserver") +SERVICE_NAME = "smtp" LOG_TARGET = os.environ.get("LOG_TARGET", "") _SMTP_BANNER = os.environ.get("SMTP_BANNER", f"220 {NODE_NAME} ESMTP Postfix (Debian/GNU)") _SMTP_MTA = os.environ.get("SMTP_MTA", NODE_NAME) -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "smtp", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class SMTPProtocol(asyncio.Protocol): diff --git a/templates/snmp/Dockerfile b/templates/snmp/Dockerfile index 6499466..a94fbc7 100644 --- a/templates/snmp/Dockerfile +++ b/templates/snmp/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/snmp/decnet_logging.py b/templates/snmp/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/snmp/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/snmp/server.py b/templates/snmp/server.py index 1d09bf4..66c1a44 100644 --- a/templates/snmp/server.py +++ b/templates/snmp/server.py @@ -12,8 +12,10 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "switch") +SERVICE_NAME = "snmp" LOG_TARGET = os.environ.get("LOG_TARGET", "") # OID value map — fake but plausible @@ -28,27 +30,13 @@ _OID_VALUES = { } -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "snmp", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) def _read_ber_length(data: bytes, pos: int): diff --git a/templates/tftp/Dockerfile b/templates/tftp/Dockerfile index f131a82..6aa974f 100644 --- a/templates/tftp/Dockerfile +++ b/templates/tftp/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/tftp/decnet_logging.py b/templates/tftp/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/tftp/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/tftp/server.py b/templates/tftp/server.py index b9f4f6e..3ca9195 100644 --- a/templates/tftp/server.py +++ b/templates/tftp/server.py @@ -11,8 +11,10 @@ import os import socket import struct from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "tftpserver") +SERVICE_NAME = "tftp" LOG_TARGET = os.environ.get("LOG_TARGET", "") # TFTP opcodes @@ -25,27 +27,13 @@ def _error_pkt(code: int, msg: str) -> bytes: return struct.pack(">HH", _ERROR, code) + msg.encode() + b"\x00" -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "tftp", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class TFTPProtocol(asyncio.DatagramProtocol): diff --git a/templates/vnc/Dockerfile b/templates/vnc/Dockerfile index 9cb1fe6..b60bcd0 100644 --- a/templates/vnc/Dockerfile +++ b/templates/vnc/Dockerfile @@ -5,6 +5,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* +COPY decnet_logging.py /opt/decnet_logging.py COPY server.py /opt/server.py COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/templates/vnc/decnet_logging.py b/templates/vnc/decnet_logging.py new file mode 100644 index 0000000..9f1f935 --- /dev/null +++ b/templates/vnc/decnet_logging.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Shared RFC 5424 syslog helper for DECNET service templates. + +Provides two functions consumed by every service's server.py: + - syslog_line(service, hostname, event_type, severity, **fields) -> str + - write_syslog_file(line: str) -> None + - forward_syslog(line: str, log_target: str) -> None + +RFC 5424 structure: + 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG + +Facility: local0 (16), PEN for SD element ID: decnet@55555 +""" + +import logging +import logging.handlers +import os +import socket +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# ─── Constants ──────────────────────────────────────────────────────────────── + +_FACILITY_LOCAL0 = 16 +_SD_ID = "decnet@55555" +_NILVALUE = "-" + +SEVERITY_EMERG = 0 +SEVERITY_ALERT = 1 +SEVERITY_CRIT = 2 +SEVERITY_ERROR = 3 +SEVERITY_WARNING = 4 +SEVERITY_NOTICE = 5 +SEVERITY_INFO = 6 +SEVERITY_DEBUG = 7 + +_MAX_HOSTNAME = 255 +_MAX_APPNAME = 48 +_MAX_MSGID = 32 + +_LOG_FILE_ENV = "DECNET_LOG_FILE" +_DEFAULT_LOG_FILE = "/var/log/decnet/decnet.log" +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + +# ─── Formatter ──────────────────────────────────────────────────────────────── + +def _sd_escape(value: str) -> str: + """Escape SD-PARAM-VALUE per RFC 5424 §6.3.3.""" + return value.replace("\\", "\\\\").replace('"', '\\"').replace("]", "\\]") + + +def _sd_element(fields: dict[str, Any]) -> str: + if not fields: + return _NILVALUE + params = " ".join(f'{k}="{_sd_escape(str(v))}"' for k, v in fields.items()) + return f"[{_SD_ID} {params}]" + + +def syslog_line( + service: str, + hostname: str, + event_type: str, + severity: int = SEVERITY_INFO, + timestamp: datetime | None = None, + msg: str | None = None, + **fields: Any, +) -> str: + """ + Return a single RFC 5424-compliant syslog line (no trailing newline). + + Args: + service: APP-NAME (e.g. "http", "mysql") + hostname: HOSTNAME (decky node name) + event_type: MSGID (e.g. "request", "login_attempt") + severity: Syslog severity integer (default: INFO=6) + timestamp: UTC datetime; defaults to now + msg: Optional free-text MSG + **fields: Encoded as structured data params + """ + pri = f"<{_FACILITY_LOCAL0 * 8 + severity}>" + ts = (timestamp or datetime.now(timezone.utc)).isoformat() + host = (hostname or _NILVALUE)[:_MAX_HOSTNAME] + appname = (service or _NILVALUE)[:_MAX_APPNAME] + msgid = (event_type or _NILVALUE)[:_MAX_MSGID] + sd = _sd_element(fields) + message = f" {msg}" if msg else "" + return f"{pri}1 {ts} {host} {appname} {_NILVALUE} {msgid} {sd}{message}" + + +# ─── File handler ───────────────────────────────────────────────────────────── + +_file_logger: logging.Logger | None = None + + +def _get_file_logger() -> logging.Logger: + global _file_logger + if _file_logger is not None: + return _file_logger + + log_path = Path(os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + except OSError: + handler = logging.StreamHandler() + + handler.setFormatter(logging.Formatter("%(message)s")) + _file_logger = logging.getLogger("decnet.syslog") + _file_logger.setLevel(logging.DEBUG) + _file_logger.propagate = False + _file_logger.addHandler(handler) + return _file_logger + + +def write_syslog_file(line: str) -> None: + """Append a syslog line to the rotating log file.""" + try: + _get_file_logger().info(line) + except Exception: + pass + + +# ─── TCP forwarding ─────────────────────────────────────────────────────────── + +def forward_syslog(line: str, log_target: str) -> None: + """Forward a syslog line over TCP to log_target (ip:port).""" + if not log_target: + return + try: + host, port = log_target.rsplit(":", 1) + with socket.create_connection((host, int(port)), timeout=3) as s: + s.sendall((line + "\n").encode()) + except Exception: + pass diff --git a/templates/vnc/server.py b/templates/vnc/server.py index 49ae10b..8d5fc52 100644 --- a/templates/vnc/server.py +++ b/templates/vnc/server.py @@ -11,35 +11,23 @@ import json import os import socket from datetime import datetime, timezone +from decnet_logging import syslog_line, write_syslog_file, forward_syslog NODE_NAME = os.environ.get("NODE_NAME", "desktop") +SERVICE_NAME = "vnc" LOG_TARGET = os.environ.get("LOG_TARGET", "") # RFB challenge — fixed so captured responses are reproducible _CHALLENGE = bytes(range(16)) * 1 + b"\x10\x11\x12\x13\x14\x15\x16\x17" # 24 bytes -def _forward(event: dict) -> None: - if not LOG_TARGET: - return - try: - host, port = LOG_TARGET.rsplit(":", 1) - with socket.create_connection((host, int(port)), timeout=3) as s: - s.sendall((json.dumps(event) + "\n").encode()) - except Exception: - pass -def _log(event_type: str, **kwargs) -> None: - event = { - "ts": datetime.now(timezone.utc).isoformat(), - "service": "vnc", - "host": NODE_NAME, - "event": event_type, - **kwargs, - } - print(json.dumps(event), flush=True) - _forward(event) +def _log(event_type: str, severity: int = 6, **kwargs) -> None: + line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs) + print(line, flush=True) + write_syslog_file(line) + forward_syslog(line, LOG_TARGET) class VNCProtocol(asyncio.Protocol): diff --git a/tests/test_file_handler.py b/tests/test_file_handler.py new file mode 100644 index 0000000..2515e4f --- /dev/null +++ b/tests/test_file_handler.py @@ -0,0 +1,71 @@ +"""Tests for the syslog file handler.""" + +import logging +import os +from pathlib import Path + +import pytest + +import decnet.logging.file_handler as fh + + +@pytest.fixture(autouse=True) +def reset_handler(tmp_path, monkeypatch): + """Reset the module-level logger between tests.""" + monkeypatch.setattr(fh, "_handler", None) + monkeypatch.setattr(fh, "_logger", None) + monkeypatch.setenv(fh._LOG_FILE_ENV, str(tmp_path / "test.log")) + yield + # Remove handlers to avoid file lock issues on next test + if fh._logger is not None: + for h in list(fh._logger.handlers): + h.close() + fh._logger.removeHandler(h) + fh._handler = None + fh._logger = None + + +def test_write_creates_log_file(tmp_path): + log_path = tmp_path / "decnet.log" + os.environ[fh._LOG_FILE_ENV] = str(log_path) + fh.write_syslog("<134>1 2026-04-04T12:00:00+00:00 h svc - e - test message") + assert log_path.exists() + assert "test message" in log_path.read_text() + + +def test_write_appends_multiple_lines(tmp_path): + log_path = tmp_path / "decnet.log" + os.environ[fh._LOG_FILE_ENV] = str(log_path) + for i in range(3): + fh.write_syslog(f"<134>1 ts host svc - event{i} -") + lines = log_path.read_text().splitlines() + assert len(lines) == 3 + assert "event0" in lines[0] + assert "event2" in lines[2] + + +def test_get_log_path_default(monkeypatch): + monkeypatch.delenv(fh._LOG_FILE_ENV, raising=False) + assert fh.get_log_path() == Path(fh._DEFAULT_LOG_FILE) + + +def test_get_log_path_custom(monkeypatch, tmp_path): + custom = str(tmp_path / "custom.log") + monkeypatch.setenv(fh._LOG_FILE_ENV, custom) + assert fh.get_log_path() == Path(custom) + + +def test_rotating_handler_configured(tmp_path): + log_path = tmp_path / "r.log" + os.environ[fh._LOG_FILE_ENV] = str(log_path) + logger = fh._get_logger() + handler = logger.handlers[0] + assert isinstance(handler, logging.handlers.RotatingFileHandler) + assert handler.maxBytes == fh._MAX_BYTES + assert handler.backupCount == fh._BACKUP_COUNT + + +def test_write_syslog_does_not_raise_on_bad_path(monkeypatch): + monkeypatch.setenv(fh._LOG_FILE_ENV, "/no/such/dir/that/exists/decnet.log") + # Should not raise — falls back to StreamHandler + fh.write_syslog("<134>1 ts h svc - e -") diff --git a/tests/test_syslog_formatter.py b/tests/test_syslog_formatter.py new file mode 100644 index 0000000..df212c4 --- /dev/null +++ b/tests/test_syslog_formatter.py @@ -0,0 +1,135 @@ +"""Tests for RFC 5424 syslog formatter.""" + +import re +from datetime import datetime, timezone + +import pytest + +from decnet.logging.syslog_formatter import ( + SEVERITY_ERROR, + SEVERITY_INFO, + SEVERITY_WARNING, + format_rfc5424, +) + +# RFC 5424 header regex: 1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID SD [MSG] +_RFC5424_RE = re.compile( + r"^<(\d+)>1 " # PRI + version + r"(\S+) " # TIMESTAMP + r"(\S+) " # HOSTNAME + r"(\S+) " # APP-NAME + r"- " # PROCID (NILVALUE) + r"(\S+) " # MSGID + r"(.+)$", # SD + optional MSG +) + + +def _parse(line: str) -> re.Match: + m = _RFC5424_RE.match(line) + assert m is not None, f"Not RFC 5424: {line!r}" + return m + + +class TestPRI: + def test_info_pri(self): + line = format_rfc5424("http", "host1", "request", SEVERITY_INFO) + m = _parse(line) + pri = int(m.group(1)) + assert pri == 16 * 8 + 6 # local0 + info = 134 + + def test_warning_pri(self): + line = format_rfc5424("http", "host1", "warn", SEVERITY_WARNING) + pri = int(_parse(line).group(1)) + assert pri == 16 * 8 + 4 # 132 + + def test_error_pri(self): + line = format_rfc5424("http", "host1", "err", SEVERITY_ERROR) + pri = int(_parse(line).group(1)) + assert pri == 16 * 8 + 3 # 131 + + def test_pri_range(self): + for sev in range(8): + line = format_rfc5424("svc", "h", "e", sev) + pri = int(_parse(line).group(1)) + assert 0 <= pri <= 191 + + +class TestTimestamp: + def test_utc_timestamp(self): + ts_str = datetime(2026, 4, 4, 12, 0, 0, tzinfo=timezone.utc).isoformat() + line = format_rfc5424("svc", "h", "e", timestamp=datetime(2026, 4, 4, 12, 0, 0, tzinfo=timezone.utc)) + m = _parse(line) + assert m.group(2) == ts_str + + def test_default_timestamp_is_utc(self): + line = format_rfc5424("svc", "h", "e") + ts_field = _parse(line).group(2) + # Should end with +00:00 or Z + assert "+" in ts_field or ts_field.endswith("Z") + + +class TestHeader: + def test_hostname(self): + line = format_rfc5424("http", "decky-01", "request") + assert _parse(line).group(3) == "decky-01" + + def test_appname(self): + line = format_rfc5424("mysql", "host", "login_attempt") + assert _parse(line).group(4) == "mysql" + + def test_msgid(self): + line = format_rfc5424("ftp", "host", "login_attempt") + assert _parse(line).group(5) == "login_attempt" + + def test_procid_is_nilvalue(self): + line = format_rfc5424("svc", "h", "e") + assert " - " in line # PROCID is always NILVALUE + + def test_appname_truncated(self): + long_name = "a" * 100 + line = format_rfc5424(long_name, "h", "e") + appname = _parse(line).group(4) + assert len(appname) <= 48 + + def test_msgid_truncated(self): + long_msgid = "x" * 100 + line = format_rfc5424("svc", "h", long_msgid) + msgid = _parse(line).group(5) + assert len(msgid) <= 32 + + +class TestStructuredData: + def test_nilvalue_when_no_fields(self): + line = format_rfc5424("svc", "h", "e") + sd_and_msg = _parse(line).group(6) + assert sd_and_msg.startswith("-") + + def test_sd_element_present(self): + line = format_rfc5424("http", "h", "request", remote_addr="1.2.3.4", method="GET") + sd_and_msg = _parse(line).group(6) + assert sd_and_msg.startswith("[decnet@55555 ") + assert 'remote_addr="1.2.3.4"' in sd_and_msg + assert 'method="GET"' in sd_and_msg + + def test_sd_escape_double_quote(self): + line = format_rfc5424("svc", "h", "e", ua='foo"bar') + assert r'ua="foo\"bar"' in line + + def test_sd_escape_backslash(self): + line = format_rfc5424("svc", "h", "e", path="a\\b") + assert r'path="a\\b"' in line + + def test_sd_escape_close_bracket(self): + line = format_rfc5424("svc", "h", "e", val="a]b") + assert r'val="a\]b"' in line + + +class TestMsg: + def test_optional_msg_appended(self): + line = format_rfc5424("svc", "h", "e", msg="hello world") + assert line.endswith(" hello world") + + def test_no_msg_no_trailing_space_in_sd(self): + line = format_rfc5424("svc", "h", "e", key="val") + # SD element closes with ] + assert line.rstrip().endswith("]")