Implement ICS/SCADA and IMAP Bait features
This commit is contained in:
15
templates/conpot/Dockerfile
Normal file
15
templates/conpot/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
ARG BASE_IMAGE=honeynet/conpot:latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
# Temporary fix: Conpot's default config binds Modbus to a non-privileged port (like 5020).
|
||||
# DECNET requires it to bind directly to 502 for the honeypot to work as expected.
|
||||
# We search the template directories and replace the port configuration.
|
||||
# This is a temporary fix pending an upstream PR from the Conpot maintainers.
|
||||
RUN find /opt /usr /etc /home -name "*.xml" -exec sed -i 's/<port>5020<\/port>/<port>502<\/port>/g' {} + 2>/dev/null || true
|
||||
RUN find /opt /usr /etc /home -name "*.xml" -exec sed -i 's/port="5020"/port="502"/g' {} + 2>/dev/null || true
|
||||
|
||||
# Switching back to the internal user if standard in conpot (falling back to nobody/conpot as appropriate)
|
||||
# Conpot image usually runs as 'conpot' user
|
||||
USER conpot
|
||||
89
templates/docker_api/decnet_logging.py
Normal file
89
templates/docker_api/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/elasticsearch/decnet_logging.py
Normal file
89
templates/elasticsearch/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/ftp/decnet_logging.py
Normal file
89
templates/ftp/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
@@ -2,10 +2,9 @@
|
||||
"""
|
||||
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
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
@@ -13,12 +12,7 @@ RFC 5424 structure:
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -40,11 +34,6 @@ _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:
|
||||
@@ -90,156 +79,11 @@ def syslog_line(
|
||||
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
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
# ─── 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
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
"""
|
||||
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
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
@@ -13,12 +12,7 @@ RFC 5424 structure:
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -40,11 +34,6 @@ _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:
|
||||
@@ -90,156 +79,11 @@ def syslog_line(
|
||||
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
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
# ─── 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
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMAPserver.
|
||||
Presents an IMAP4rev1 banner, captures LOGIN credentials (plaintext and
|
||||
AUTHENTICATE), then returns a NO response. Logs all commands as JSON.
|
||||
IMAP server (port 143/993).
|
||||
Presents an IMAP4rev1 banner, captures LOGIN credentials.
|
||||
Implements a basic IMAP state machine (NOT_AUTHENTICATED -> AUTHENTICATED -> SELECTED).
|
||||
Provides hardcoded bait emails containing AWS API keys to attackers.
|
||||
Logs commands as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -12,10 +14,14 @@ 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"
|
||||
|
||||
IMAP_BANNER = os.environ.get("IMAP_BANNER", f"* OK [{NODE_NAME}] Dovecot ready.\r\n")
|
||||
|
||||
IMAP_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor")
|
||||
|
||||
_BAIT_EMAILS = [
|
||||
(1, "Date: Tue, 01 Nov 2023 10:00:00 +0000\r\nFrom: sysadmin@company.com\r\nSubject: AWS Credentials\r\n\r\nHere are the new AWS keys:\r\nAKIAIOSFODNN7EXAMPLE\r\nwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\r\n"),
|
||||
(2, "Date: Wed, 02 Nov 2023 11:30:00 +0000\r\nFrom: devops@company.com\r\nSubject: DB Password Reset\r\n\r\nThe production database password has been temporarily set to:\r\nProdDB_temp_2023!!\r\n"),
|
||||
]
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
@@ -23,18 +29,24 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
class IMAPProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
self._state = "NOT_AUTHENTICATED"
|
||||
self._valid_users = dict(u.split(":", 1) for u in IMAP_USERS.split(",") if ":" in u)
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
transport.write(BANNER.encode())
|
||||
if IMAP_BANNER:
|
||||
if not IMAP_BANNER.endswith("\r\n"):
|
||||
padded_banner = IMAP_BANNER + "\r\n"
|
||||
else:
|
||||
padded_banner = IMAP_BANNER
|
||||
transport.write(padded_banner.encode())
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
@@ -50,22 +62,60 @@ class IMAPProtocol(asyncio.Protocol):
|
||||
cmd = parts[1].upper() if len(parts) > 1 else ""
|
||||
args = parts[2] if len(parts) > 2 else ""
|
||||
|
||||
if cmd == "LOGIN":
|
||||
_log("command", src=self._peer[0], cmd=line[:128], state=self._state)
|
||||
|
||||
if cmd == "CAPABILITY":
|
||||
self._transport.write(b"* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN\r\n")
|
||||
self._transport.write(f"{tag} OK CAPABILITY completed\r\n".encode())
|
||||
|
||||
elif cmd == "LOGIN":
|
||||
if self._state != "NOT_AUTHENTICATED":
|
||||
self._transport.write(f"{tag} BAD Already authenticated\r\n".encode())
|
||||
return
|
||||
creds = args.split(None, 1)
|
||||
username = creds[0].strip('"') if creds else ""
|
||||
password = creds[1].strip('"') if len(creds) > 1 else ""
|
||||
_log("auth", src=self._peer[0], username=username, password=password)
|
||||
self._transport.write(f"{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n".encode())
|
||||
elif cmd == "CAPABILITY":
|
||||
self._transport.write(b"* CAPABILITY IMAP4rev1 AUTH=PLAIN AUTH=LOGIN\r\n")
|
||||
self._transport.write(f"{tag} OK CAPABILITY completed\r\n".encode())
|
||||
|
||||
if username in self._valid_users and self._valid_users[username] == password:
|
||||
self._state = "AUTHENTICATED"
|
||||
_log("auth", src=self._peer[0], username=username, password=password, status="success")
|
||||
self._transport.write(f"{tag} OK [CAPABILITY IMAP4rev1] Logged in\r\n".encode())
|
||||
else:
|
||||
_log("auth", src=self._peer[0], username=username, password=password, status="failed")
|
||||
self._transport.write(f"{tag} NO [AUTHENTICATIONFAILED] Authentication failed.\r\n".encode())
|
||||
|
||||
elif cmd == "SELECT" or cmd == "EXAMINE":
|
||||
if self._state == "NOT_AUTHENTICATED":
|
||||
self._transport.write(f"{tag} BAD Not authenticated\r\n".encode())
|
||||
return
|
||||
|
||||
self._state = "SELECTED"
|
||||
count = len(_BAIT_EMAILS)
|
||||
self._transport.write(f"* {count} EXISTS\r\n* 0 RECENT\r\n* OK [UIDVALIDITY 1] UIDs valid\r\n".encode())
|
||||
self._transport.write(f"{tag} OK [READ-WRITE] Select completed.\r\n".encode())
|
||||
|
||||
elif cmd == "FETCH":
|
||||
if self._state != "SELECTED":
|
||||
self._transport.write(f"{tag} BAD Not selected\r\n".encode())
|
||||
return
|
||||
|
||||
# rudimentary fetch match simply returning all if any match
|
||||
# an attacker usually sends "FETCH 1:* (BODY[])" or similar
|
||||
if "RFC822" in args.upper() or "BODY" in args.upper():
|
||||
for uid, content in _BAIT_EMAILS:
|
||||
content_encoded = content.encode()
|
||||
self._transport.write(f"* {uid} FETCH (RFC822 {{{len(content_encoded)}}}\r\n".encode())
|
||||
self._transport.write(content_encoded)
|
||||
self._transport.write(b")\r\n")
|
||||
self._transport.write(f"{tag} OK Fetch completed.\r\n".encode())
|
||||
|
||||
elif cmd == "LOGOUT":
|
||||
self._transport.write(b"* BYE IMAP4rev1 Server logging out\r\n")
|
||||
self._transport.write(f"{tag} OK LOGOUT completed\r\n".encode())
|
||||
self._transport.write(b"* BYE Logging out\r\n")
|
||||
self._transport.write(f"{tag} OK Logout completed.\r\n".encode())
|
||||
self._transport.close()
|
||||
|
||||
else:
|
||||
_log("command", src=self._peer[0], cmd=line[:128])
|
||||
self._transport.write(f"{tag} BAD Command not recognized\r\n".encode())
|
||||
self._transport.write(f"{tag} BAD Command not recognized or unsupported\r\n".encode())
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
89
templates/k8s/decnet_logging.py
Normal file
89
templates/k8s/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/ldap/decnet_logging.py
Normal file
89
templates/ldap/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/llmnr/decnet_logging.py
Normal file
89
templates/llmnr/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/mongodb/decnet_logging.py
Normal file
89
templates/mongodb/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/mqtt/decnet_logging.py
Normal file
89
templates/mqtt/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
@@ -1,26 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MQTT server (port 1883).
|
||||
Parses MQTT CONNECT packets, extracts client_id, username, and password,
|
||||
then returns CONNACK with return code 5 (not authorized). Logs all
|
||||
interactions as JSON.
|
||||
Parses MQTT CONNECT packets, extracts client_id, etc.
|
||||
Responds with CONNACK.
|
||||
Supports dynamic topics and retained publishes.
|
||||
Logs PUBLISH commands sent by clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import struct
|
||||
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", "")
|
||||
MQTT_ACCEPT_ALL = os.environ.get("MQTT_ACCEPT_ALL", "1") == "1"
|
||||
MQTT_PERSONA = os.environ.get("MQTT_PERSONA", "water_plant")
|
||||
MQTT_CUSTOM_TOPICS = os.environ.get("MQTT_CUSTOM_TOPICS", "")
|
||||
|
||||
# CONNACK: packet type 0x20, remaining length 2, session_present=0, return_code=5
|
||||
_CONNACK_ACCEPTED = b"\x20\x02\x00\x00"
|
||||
_CONNACK_NOT_AUTH = b"\x20\x02\x00\x05"
|
||||
|
||||
|
||||
|
||||
|
||||
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)
|
||||
@@ -38,45 +42,128 @@ def _read_utf8(data: bytes, pos: int):
|
||||
|
||||
|
||||
def _parse_connect(payload: bytes):
|
||||
"""Extract client_id, username, password from MQTT CONNECT payload."""
|
||||
pos = 0
|
||||
# Protocol name
|
||||
proto_name, pos = _read_utf8(payload, pos)
|
||||
# Protocol level (1 byte)
|
||||
if pos >= len(payload):
|
||||
return {}, pos
|
||||
_proto_level = payload[pos]
|
||||
pos += 1
|
||||
# Connect flags (1 byte)
|
||||
if pos >= len(payload):
|
||||
return {}, pos
|
||||
flags = payload[pos]
|
||||
pos += 1
|
||||
# Keep alive (2 bytes)
|
||||
pos += 2
|
||||
# Client ID
|
||||
pos += 2 # Keep alive
|
||||
client_id, pos = _read_utf8(payload, pos)
|
||||
result = {"client_id": client_id, "proto": proto_name}
|
||||
# Will flag
|
||||
if flags & 0x04:
|
||||
_, pos = _read_utf8(payload, pos) # will topic
|
||||
_, pos = _read_utf8(payload, pos) # will message
|
||||
# Username flag
|
||||
_, pos = _read_utf8(payload, pos)
|
||||
_, pos = _read_utf8(payload, pos)
|
||||
if flags & 0x80:
|
||||
username, pos = _read_utf8(payload, pos)
|
||||
result["username"] = username
|
||||
# Password flag
|
||||
if flags & 0x40:
|
||||
password, pos = _read_utf8(payload, pos)
|
||||
result["password"] = password
|
||||
return result
|
||||
|
||||
|
||||
def _parse_subscribe(payload: bytes):
|
||||
"""Returns (packet_id, [(topic, qos), ...])"""
|
||||
if len(payload) < 2:
|
||||
return 0, []
|
||||
pos = 0
|
||||
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
|
||||
pos += 2
|
||||
topics = []
|
||||
while pos < len(payload):
|
||||
topic, pos = _read_utf8(payload, pos)
|
||||
if pos >= len(payload):
|
||||
break
|
||||
qos = payload[pos] & 0x03
|
||||
pos += 1
|
||||
topics.append((topic, qos))
|
||||
return packet_id, topics
|
||||
|
||||
|
||||
def _suback(packet_id: int, granted_qos: list[int]) -> bytes:
|
||||
payload = struct.pack(">H", packet_id) + bytes(granted_qos)
|
||||
return bytes([0x90, len(payload)]) + payload
|
||||
|
||||
|
||||
def _publish(topic: str, value: str, retain: bool = True) -> bytes:
|
||||
topic_bytes = topic.encode()
|
||||
topic_len = struct.pack(">H", len(topic_bytes))
|
||||
payload = str(value).encode()
|
||||
fixed = 0x31 if retain else 0x30
|
||||
remaining = len(topic_len) + len(topic_bytes) + len(payload)
|
||||
|
||||
# variable length encoding
|
||||
rem_bytes = []
|
||||
while remaining > 0:
|
||||
encoded = remaining % 128
|
||||
remaining = remaining // 128
|
||||
if remaining > 0:
|
||||
encoded = encoded | 128
|
||||
rem_bytes.append(encoded)
|
||||
if not rem_bytes:
|
||||
rem_bytes = [0]
|
||||
|
||||
return bytes([fixed]) + bytes(rem_bytes) + topic_len + topic_bytes + payload
|
||||
|
||||
|
||||
def _parse_publish(payload: bytes, qos: int):
|
||||
pos = 0
|
||||
topic, pos = _read_utf8(payload, pos)
|
||||
packet_id = 0
|
||||
if qos > 0:
|
||||
if pos + 2 <= len(payload):
|
||||
packet_id = struct.unpack(">H", payload[pos:pos+2])[0]
|
||||
pos += 2
|
||||
data = payload[pos:]
|
||||
return topic, packet_id, data
|
||||
|
||||
|
||||
def _generate_topics() -> dict:
|
||||
topics: dict = {}
|
||||
if MQTT_CUSTOM_TOPICS:
|
||||
try:
|
||||
topics = json.loads(MQTT_CUSTOM_TOPICS)
|
||||
return topics
|
||||
except Exception as e:
|
||||
_log("config_error", severity=4, error=str(e))
|
||||
|
||||
if MQTT_PERSONA == "water_plant":
|
||||
topics.update({
|
||||
"plant/water/tank1/level": f"{random.uniform(60.0, 80.0):.1f}",
|
||||
"plant/water/tank1/pressure": f"{random.uniform(2.5, 3.0):.2f}",
|
||||
"plant/water/pump1/status": "RUNNING",
|
||||
"plant/water/pump1/rpm": f"{int(random.uniform(1400, 1450))}",
|
||||
"plant/water/pump2/status": "STANDBY",
|
||||
"plant/water/chlorine/dosing": f"{random.uniform(1.1, 1.3):.1f}",
|
||||
"plant/water/chlorine/residual": f"{random.uniform(0.7, 0.9):.1f}",
|
||||
"plant/water/valve/inlet/state": "OPEN",
|
||||
"plant/water/valve/drain/state": "CLOSED",
|
||||
"plant/alarm/high_pressure": "0",
|
||||
"plant/alarm/low_chlorine": "0",
|
||||
"plant/alarm/pump_fault": "0",
|
||||
"plant/$SYS/broker/version": "Mosquitto 2.0.15",
|
||||
"plant/$SYS/broker/uptime": "2847392",
|
||||
})
|
||||
elif not topics:
|
||||
topics = {
|
||||
"device/status": "online",
|
||||
"device/uptime": "3600"
|
||||
}
|
||||
return topics
|
||||
|
||||
|
||||
class MQTTProtocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._buf = b""
|
||||
self._auth = False
|
||||
self._topics = _generate_topics()
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
@@ -85,11 +172,20 @@ class MQTTProtocol(asyncio.Protocol):
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
self._process()
|
||||
try:
|
||||
self._process()
|
||||
except Exception as e:
|
||||
_log("protocol_error", severity=4, error=str(e))
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
|
||||
def _process(self):
|
||||
while len(self._buf) >= 2:
|
||||
pkt_type = (self._buf[0] >> 4) & 0x0f
|
||||
pkt_byte = self._buf[0]
|
||||
pkt_type = (pkt_byte >> 4) & 0x0f
|
||||
flags = pkt_byte & 0x0f
|
||||
qos = (flags >> 1) & 0x03
|
||||
|
||||
# Decode remaining length (variable-length encoding)
|
||||
pos = 1
|
||||
remaining = 0
|
||||
@@ -110,11 +206,49 @@ class MQTTProtocol(asyncio.Protocol):
|
||||
|
||||
if pkt_type == 1: # CONNECT
|
||||
info = _parse_connect(payload)
|
||||
_log("auth", src=self._peer[0], **info)
|
||||
self._transport.write(_CONNACK_NOT_AUTH)
|
||||
self._transport.close()
|
||||
_log("auth", **info)
|
||||
if MQTT_ACCEPT_ALL:
|
||||
self._auth = True
|
||||
self._transport.write(_CONNACK_ACCEPTED)
|
||||
else:
|
||||
self._transport.write(_CONNACK_NOT_AUTH)
|
||||
self._transport.close()
|
||||
elif pkt_type == 8: # SUBSCRIBE
|
||||
if not self._auth:
|
||||
self._transport.close()
|
||||
continue
|
||||
packet_id, subs = _parse_subscribe(payload)
|
||||
granted_qos = [1] * len(subs) # grant QoS 1 for all
|
||||
self._transport.write(_suback(packet_id, granted_qos))
|
||||
|
||||
# Immediately send retained publishes matching topics
|
||||
for sub_topic, _ in subs:
|
||||
_log("subscribe", src=self._peer[0], topics=[sub_topic])
|
||||
for t, v in self._topics.items():
|
||||
# simple match: if topic ends with #, it matches prefix
|
||||
if sub_topic.endswith("#"):
|
||||
prefix = sub_topic[:-1]
|
||||
if t.startswith(prefix):
|
||||
self._transport.write(_publish(t, str(v)))
|
||||
elif sub_topic == t:
|
||||
self._transport.write(_publish(t, str(v)))
|
||||
|
||||
elif pkt_type == 3: # PUBLISH
|
||||
if not self._auth:
|
||||
self._transport.close()
|
||||
continue
|
||||
topic, packet_id, data = _parse_publish(payload, qos)
|
||||
# Attacker command received!
|
||||
_log("publish", src=self._peer[0], topic=topic, payload=data.decode(errors="replace"))
|
||||
|
||||
if qos == 1:
|
||||
puback = bytes([0x40, 0x02]) + struct.pack(">H", packet_id)
|
||||
self._transport.write(puback)
|
||||
|
||||
elif pkt_type == 12: # PINGREQ
|
||||
self._transport.write(b"\xd0\x00") # PINGRESP
|
||||
elif pkt_type == 14: # DISCONNECT
|
||||
self._transport.close()
|
||||
else:
|
||||
_log("packet", src=self._peer[0], pkt_type=pkt_type)
|
||||
self._transport.close()
|
||||
|
||||
89
templates/mssql/decnet_logging.py
Normal file
89
templates/mssql/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/mysql/decnet_logging.py
Normal file
89
templates/mysql/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
@@ -2,10 +2,9 @@
|
||||
"""
|
||||
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
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
@@ -13,12 +12,7 @@ RFC 5424 structure:
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -40,11 +34,6 @@ _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:
|
||||
@@ -90,156 +79,11 @@ def syslog_line(
|
||||
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
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
# ─── 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
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
POP3server.
|
||||
Presents a convincing POP3 banner, collects USER/PASS credentials, then
|
||||
stalls with a generic error. Logs every interaction as JSON and forwards
|
||||
to LOG_TARGET if set.
|
||||
POP3 server (port 110/995).
|
||||
Presents a POP3 banner, captures USER and PASS credentials.
|
||||
Implements a basic POP3 state machine (AUTHORIZATION -> TRANSACTION).
|
||||
Provides hardcoded bait emails.
|
||||
Logs commands as JSON.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -13,10 +14,14 @@ 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"
|
||||
|
||||
POP3_BANNER = os.environ.get("IMAP_BANNER", f"+OK [{NODE_NAME}] Dovecot ready.\r\n")
|
||||
|
||||
IMAP_USERS = os.environ.get("IMAP_USERS", "admin:admin123,root:toor")
|
||||
|
||||
_BAIT_EMAILS = [
|
||||
"Date: Tue, 01 Nov 2023 10:00:00 +0000\r\nFrom: sysadmin@company.com\r\nSubject: AWS Credentials\r\n\r\nHere are the new AWS keys:\r\nAKIAIOSFODNN7EXAMPLE\r\nwJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\r\n",
|
||||
"Date: Wed, 02 Nov 2023 11:30:00 +0000\r\nFrom: devops@company.com\r\nSubject: DB Password Reset\r\n\r\nThe production database password has been temporarily set to:\r\nProdDB_temp_2023!!\r\n",
|
||||
]
|
||||
|
||||
def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
line = syslog_line(SERVICE_NAME, NODE_NAME, event_type, severity, **kwargs)
|
||||
@@ -24,19 +29,27 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
write_syslog_file(line)
|
||||
forward_syslog(line, LOG_TARGET)
|
||||
|
||||
|
||||
class POP3Protocol(asyncio.Protocol):
|
||||
def __init__(self):
|
||||
self._transport = None
|
||||
self._peer = None
|
||||
self._user = None
|
||||
self._buf = b""
|
||||
self._state = "AUTHORIZATION"
|
||||
self._valid_users = dict(u.split(":", 1) for u in IMAP_USERS.split(",") if ":" in u)
|
||||
self._current_user = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self._transport = transport
|
||||
self._peer = transport.get_extra_info("peername", ("?", 0))
|
||||
_log("connect", src=self._peer[0], src_port=self._peer[1])
|
||||
transport.write(BANNER.encode())
|
||||
if POP3_BANNER:
|
||||
if not POP3_BANNER.endswith("\r\n"):
|
||||
padded_banner = POP3_BANNER + "\r\n"
|
||||
else:
|
||||
padded_banner = POP3_BANNER
|
||||
if not padded_banner.startswith("+OK"):
|
||||
padded_banner = "+OK " + padded_banner.lstrip("* OK ") # replace IMAP prefix with POP3
|
||||
transport.write(padded_banner.encode())
|
||||
|
||||
def data_received(self, data):
|
||||
self._buf += data
|
||||
@@ -45,28 +58,98 @@ class POP3Protocol(asyncio.Protocol):
|
||||
self._handle_line(line.decode(errors="replace").strip())
|
||||
|
||||
def _handle_line(self, line: str):
|
||||
upper = line.upper()
|
||||
if upper.startswith("USER "):
|
||||
self._user = line[5:].strip()
|
||||
_log("user", src=self._peer[0], username=self._user)
|
||||
self._transport.write(b"+OK\r\n")
|
||||
elif upper.startswith("PASS "):
|
||||
password = line[5:].strip()
|
||||
_log("auth", src=self._peer[0], username=self._user, password=password)
|
||||
self._transport.write(b"-ERR Authentication failed\r\n")
|
||||
elif upper == "QUIT":
|
||||
self._transport.write(b"+OK Bye\r\n")
|
||||
self._transport.close()
|
||||
elif upper == "CAPA":
|
||||
parts = line.split(None, 1)
|
||||
if not parts:
|
||||
return
|
||||
cmd = parts[0].upper()
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
_log("command", src=self._peer[0], cmd=line[:128], state=self._state)
|
||||
|
||||
if cmd == "CAPA":
|
||||
self._transport.write(b"+OK Capability list follows\r\nUSER\r\n.\r\n")
|
||||
|
||||
elif cmd == "USER":
|
||||
if self._state != "AUTHORIZATION":
|
||||
self._transport.write(b"-ERR Already authenticated.\r\n")
|
||||
return
|
||||
self._current_user = args
|
||||
self._transport.write(b"+OK User name accepted, password please\r\n")
|
||||
|
||||
elif cmd == "PASS":
|
||||
if self._state != "AUTHORIZATION":
|
||||
self._transport.write(b"-ERR Already authenticated.\r\n")
|
||||
return
|
||||
if not self._current_user:
|
||||
self._transport.write(b"-ERR USER required first.\r\n")
|
||||
return
|
||||
|
||||
password = args
|
||||
username = self._current_user
|
||||
|
||||
if username in self._valid_users and self._valid_users[username] == password:
|
||||
self._state = "TRANSACTION"
|
||||
_log("auth", src=self._peer[0], username=username, password=password, status="success")
|
||||
self._transport.write(b"+OK Logged in.\r\n")
|
||||
else:
|
||||
_log("auth", src=self._peer[0], username=username, password=password, status="failed")
|
||||
self._transport.write(b"-ERR Authentication failed.\r\n")
|
||||
self._current_user = None
|
||||
|
||||
elif cmd == "STAT":
|
||||
if self._state != "TRANSACTION":
|
||||
self._transport.write(b"-ERR Not authenticated\r\n")
|
||||
return
|
||||
total_size = sum(len(e) for e in _BAIT_EMAILS)
|
||||
self._transport.write(f"+OK {len(_BAIT_EMAILS)} {total_size}\r\n".encode())
|
||||
|
||||
elif cmd == "LIST":
|
||||
if self._state != "TRANSACTION":
|
||||
self._transport.write(b"-ERR Not authenticated\r\n")
|
||||
return
|
||||
|
||||
if args:
|
||||
try:
|
||||
idx = int(args) - 1
|
||||
if 0 <= idx < len(_BAIT_EMAILS):
|
||||
self._transport.write(f"+OK {idx + 1} {len(_BAIT_EMAILS[idx])}\r\n".encode())
|
||||
else:
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
except ValueError:
|
||||
self._transport.write(b"-ERR Invalid argument\r\n")
|
||||
else:
|
||||
total_size = sum(len(e) for e in _BAIT_EMAILS)
|
||||
self._transport.write(f"+OK {len(_BAIT_EMAILS)} messages ({total_size} octets)\r\n".encode())
|
||||
for i, email in enumerate(_BAIT_EMAILS):
|
||||
self._transport.write(f"{i + 1} {len(email)}\r\n".encode())
|
||||
self._transport.write(b".\r\n")
|
||||
|
||||
elif cmd == "RETR":
|
||||
if self._state != "TRANSACTION":
|
||||
self._transport.write(b"-ERR Not authenticated\r\n")
|
||||
return
|
||||
try:
|
||||
idx = int(args) - 1
|
||||
if 0 <= idx < len(_BAIT_EMAILS):
|
||||
email = _BAIT_EMAILS[idx]
|
||||
self._transport.write(f"+OK {len(email)} octets\r\n".encode())
|
||||
self._transport.write(email.encode())
|
||||
self._transport.write(b".\r\n")
|
||||
else:
|
||||
self._transport.write(b"-ERR No such message\r\n")
|
||||
except ValueError:
|
||||
self._transport.write(b"-ERR Invalid argument\r\n")
|
||||
|
||||
elif cmd == "QUIT":
|
||||
self._transport.write(b"+OK Logging out.\r\n")
|
||||
self._transport.close()
|
||||
|
||||
else:
|
||||
_log("command", src=self._peer[0], cmd=line[:128])
|
||||
self._transport.write(b"-ERR Unknown command\r\n")
|
||||
self._transport.write(b"-ERR Command not recognized\r\n")
|
||||
|
||||
def connection_lost(self, exc):
|
||||
_log("disconnect", src=self._peer[0] if self._peer else "?")
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"POP3 server starting as {NODE_NAME}")
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -74,6 +157,5 @@ async def main():
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
89
templates/postgres/decnet_logging.py
Normal file
89
templates/postgres/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/rdp/decnet_logging.py
Normal file
89
templates/rdp/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/redis/decnet_logging.py
Normal file
89
templates/redis/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/sip/decnet_logging.py
Normal file
89
templates/sip/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/smb/decnet_logging.py
Normal file
89
templates/smb/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
@@ -2,10 +2,9 @@
|
||||
"""
|
||||
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
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
@@ -13,12 +12,7 @@ RFC 5424 structure:
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
@@ -40,11 +34,6 @@ _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:
|
||||
@@ -90,156 +79,11 @@ def syslog_line(
|
||||
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
|
||||
|
||||
|
||||
|
||||
_json_logger: logging.Logger | None = None
|
||||
|
||||
def _get_json_logger() -> logging.Logger:
|
||||
global _json_logger
|
||||
if _json_logger is not None:
|
||||
return _json_logger
|
||||
|
||||
log_path_str = os.environ.get(_LOG_FILE_ENV, _DEFAULT_LOG_FILE)
|
||||
json_path = Path(log_path_str).with_suffix(".json")
|
||||
try:
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
json_path,
|
||||
maxBytes=_MAX_BYTES,
|
||||
backupCount=_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
handler = logging.StreamHandler()
|
||||
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
_json_logger = logging.getLogger("decnet.json")
|
||||
_json_logger.setLevel(logging.DEBUG)
|
||||
_json_logger.propagate = False
|
||||
_json_logger.addHandler(handler)
|
||||
return _json_logger
|
||||
|
||||
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Append a syslog line to the rotating log file."""
|
||||
try:
|
||||
_get_file_logger().info(line)
|
||||
|
||||
# Also parse and write JSON log
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Any
|
||||
|
||||
_RFC5424_RE: re.Pattern = re.compile(
|
||||
r"^<\d+>1 "
|
||||
r"(\S+) " # 1: TIMESTAMP
|
||||
r"(\S+) " # 2: HOSTNAME (decky name)
|
||||
r"(\S+) " # 3: APP-NAME (service)
|
||||
r"- " # PROCID always NILVALUE
|
||||
r"(\S+) " # 4: MSGID (event_type)
|
||||
r"(.+)$", # 5: SD element + optional MSG
|
||||
)
|
||||
_SD_BLOCK_RE: re.Pattern = re.compile(r'\[decnet@55555\s+(.*?)\]', re.DOTALL)
|
||||
_PARAM_RE: re.Pattern = re.compile(r'(\w+)="((?:[^"\\]|\\.)*)"')
|
||||
_IP_FIELDS: tuple[str, ...] = ("src_ip", "src", "client_ip", "remote_ip", "ip")
|
||||
|
||||
_m: Optional[re.Match] = _RFC5424_RE.match(line)
|
||||
if _m:
|
||||
_ts_raw: str
|
||||
_decky: str
|
||||
_service: str
|
||||
_event_type: str
|
||||
_sd_rest: str
|
||||
_ts_raw, _decky, _service, _event_type, _sd_rest = _m.groups()
|
||||
|
||||
_fields: dict[str, str] = {}
|
||||
_msg: str = ""
|
||||
|
||||
if _sd_rest.startswith("-"):
|
||||
_msg = _sd_rest[1:].lstrip()
|
||||
elif _sd_rest.startswith("["):
|
||||
_block: Optional[re.Match] = _SD_BLOCK_RE.search(_sd_rest)
|
||||
if _block:
|
||||
for _k, _v in _PARAM_RE.findall(_block.group(1)):
|
||||
_fields[_k] = _v.replace('\\"', '"').replace("\\\\", "\\").replace("\\]", "]")
|
||||
|
||||
# extract msg after the block
|
||||
_msg_match: Optional[re.Match] = re.search(r'\]\s+(.+)$', _sd_rest)
|
||||
if _msg_match:
|
||||
_msg = _msg_match.group(1).strip()
|
||||
else:
|
||||
_msg = _sd_rest
|
||||
|
||||
_attacker_ip: str = "Unknown"
|
||||
for _fname in _IP_FIELDS:
|
||||
if _fname in _fields:
|
||||
_attacker_ip = _fields[_fname]
|
||||
break
|
||||
|
||||
# Parse timestamp to normalize it
|
||||
_ts_formatted: str
|
||||
try:
|
||||
_ts_formatted = datetime.fromisoformat(_ts_raw).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
_ts_formatted = _ts_raw
|
||||
|
||||
_payload: dict[str, Any] = {
|
||||
"timestamp": _ts_formatted,
|
||||
"decky": _decky,
|
||||
"service": _service,
|
||||
"event_type": _event_type,
|
||||
"attacker_ip": _attacker_ip,
|
||||
"fields": json.dumps(_fields),
|
||||
"msg": _msg,
|
||||
"raw_line": line
|
||||
}
|
||||
_get_json_logger().info(json.dumps(_payload))
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
# ─── 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
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
|
||||
89
templates/snmp/decnet_logging.py
Normal file
89
templates/snmp/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
@@ -14,21 +14,58 @@ 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", "")
|
||||
SNMP_ARCHETYPE = os.environ.get("SNMP_ARCHETYPE", "default")
|
||||
|
||||
|
||||
def _get_archetype_values() -> dict:
|
||||
archetypes = {
|
||||
"water_plant": {
|
||||
"sysDescr": f"Linux {NODE_NAME} 4.19.0-18-amd64 #1 SMP Debian 4.19.208-1 (2021-09-29) x86_64",
|
||||
"sysContact": "ICS Admin <ics-admin@plant.local>",
|
||||
"sysName": NODE_NAME,
|
||||
"sysLocation": "Water Treatment Facility — Pump Room B",
|
||||
},
|
||||
"factory": {
|
||||
"sysDescr": "VxWorks 6.9 (Rockwell Automation Allen-Bradley ControlLogix 5580)",
|
||||
"sysContact": "Factory Floor Support <support@factory.local>",
|
||||
"sysName": NODE_NAME,
|
||||
"sysLocation": "Factory Floor",
|
||||
},
|
||||
"substation": {
|
||||
"sysDescr": "SEL Real-Time Automation Controller RTAC SEL-3555 firmware 1.9.7.0",
|
||||
"sysContact": "Grid Ops <gridops@utility.local>",
|
||||
"sysName": NODE_NAME,
|
||||
"sysLocation": "Main Substation",
|
||||
},
|
||||
"hospital": {
|
||||
"sysDescr": f"Linux {NODE_NAME} 5.10.0-21-amd64 #1 SMP Debian 5.10.162-1 x86_64",
|
||||
"sysContact": "Medical IT <medit@hospital.local>",
|
||||
"sysName": NODE_NAME,
|
||||
"sysLocation": "ICU Ward 3",
|
||||
},
|
||||
"default": {
|
||||
"sysDescr": f"Linux {NODE_NAME} 5.15.0-91-generic #101-Ubuntu SMP Tue Nov 14 13:30:08 UTC 2023 x86_64",
|
||||
"sysContact": "admin@localhost",
|
||||
"sysName": NODE_NAME,
|
||||
"sysLocation": "Server Room",
|
||||
}
|
||||
}
|
||||
return archetypes.get(SNMP_ARCHETYPE, archetypes["default"])
|
||||
|
||||
_arch = _get_archetype_values()
|
||||
|
||||
# OID value map — fake but plausible
|
||||
_OID_VALUES = {
|
||||
"1.3.6.1.2.1.1.1.0": f"Linux {NODE_NAME} 5.15.0-76-generic #83-Ubuntu SMP x86_64",
|
||||
"1.3.6.1.2.1.1.1.0": _arch["sysDescr"],
|
||||
"1.3.6.1.2.1.1.2.0": "1.3.6.1.4.1.8072.3.2.10",
|
||||
"1.3.6.1.2.1.1.3.0": "12345678", # sysUpTime
|
||||
"1.3.6.1.2.1.1.4.0": "admin@localhost",
|
||||
"1.3.6.1.2.1.1.5.0": NODE_NAME,
|
||||
"1.3.6.1.2.1.1.6.0": "Server Room",
|
||||
"1.3.6.1.2.1.1.4.0": _arch["sysContact"],
|
||||
"1.3.6.1.2.1.1.5.0": _arch["sysName"],
|
||||
"1.3.6.1.2.1.1.6.0": _arch["sysLocation"],
|
||||
"1.3.6.1.2.1.1.7.0": "72",
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
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)
|
||||
@@ -37,10 +74,14 @@ def _log(event_type: str, severity: int = 6, **kwargs) -> None:
|
||||
|
||||
|
||||
def _read_ber_length(data: bytes, pos: int):
|
||||
if pos >= len(data):
|
||||
raise ValueError("Unexpected end of data reading ASN.1 length")
|
||||
b = data[pos]
|
||||
if b < 0x80:
|
||||
return b, pos + 1
|
||||
n = b & 0x7f
|
||||
if pos + 1 + n > len(data):
|
||||
raise ValueError("BER length bytes truncated")
|
||||
length = int.from_bytes(data[pos + 1:pos + 1 + n], "big")
|
||||
return length, pos + 1 + n
|
||||
|
||||
@@ -91,42 +132,67 @@ def _ber_tlv(tag: int, value: bytes) -> bytes:
|
||||
def _parse_snmp(data: bytes):
|
||||
"""Return (version, community, request_id, oids) or raise."""
|
||||
pos = 0
|
||||
assert data[pos] == 0x30 # nosec B101
|
||||
if len(data) == 0 or data[pos] != 0x30:
|
||||
raise ValueError("Not a valid ASN.1 sequence")
|
||||
pos += 1
|
||||
_, pos = _read_ber_length(data, pos)
|
||||
# version
|
||||
assert data[pos] == 0x02 # nosec B101
|
||||
if pos >= len(data) or data[pos] != 0x02:
|
||||
raise ValueError("Expected SNMP version INTEGER")
|
||||
pos += 1
|
||||
v_len, pos = _read_ber_length(data, pos)
|
||||
version = int.from_bytes(data[pos:pos + v_len], "big")
|
||||
pos += v_len
|
||||
# community
|
||||
assert data[pos] == 0x04 # nosec B101
|
||||
if pos >= len(data) or data[pos] != 0x04:
|
||||
raise ValueError("Expected SNMP community OCTET STREAM")
|
||||
pos += 1
|
||||
c_len, pos = _read_ber_length(data, pos)
|
||||
community = data[pos:pos + c_len].decode(errors="replace")
|
||||
pos += c_len
|
||||
# PDU type (0xa0 = GetRequest, 0xa1 = GetNextRequest)
|
||||
if pos >= len(data):
|
||||
raise ValueError("Missing PDU type")
|
||||
|
||||
pdu_type = data[pos]
|
||||
if pdu_type not in (0xa0, 0xa1):
|
||||
raise ValueError(f"Invalid PDU type {pdu_type}")
|
||||
|
||||
pos += 1
|
||||
_, pos = _read_ber_length(data, pos)
|
||||
# request-id
|
||||
assert data[pos] == 0x02 # nosec B101
|
||||
if pos >= len(data) or data[pos] != 0x02:
|
||||
raise ValueError("Expected Request ID INTEGER")
|
||||
pos += 1
|
||||
r_len, pos = _read_ber_length(data, pos)
|
||||
request_id = int.from_bytes(data[pos:pos + r_len], "big")
|
||||
pos += r_len
|
||||
pos += 4 # skip error-status and error-index
|
||||
# skip error-status
|
||||
if pos >= len(data) or data[pos] != 0x02:
|
||||
raise ValueError("Expected error-status INTEGER")
|
||||
pos += 1
|
||||
e_len, pos = _read_ber_length(data, pos)
|
||||
pos += e_len
|
||||
# skip error-index
|
||||
if pos >= len(data) or data[pos] != 0x02:
|
||||
raise ValueError("Expected error-index INTEGER")
|
||||
pos += 1
|
||||
i_len, pos = _read_ber_length(data, pos)
|
||||
pos += i_len
|
||||
# varbind list
|
||||
assert data[pos] == 0x30 # nosec B101
|
||||
if pos >= len(data) or data[pos] != 0x30:
|
||||
raise ValueError("Expected varbind list SEQUENCE")
|
||||
pos += 1
|
||||
vbl_len, pos = _read_ber_length(data, pos)
|
||||
end = pos + vbl_len
|
||||
oids = []
|
||||
while pos < end:
|
||||
assert data[pos] == 0x30 # nosec B101
|
||||
if data[pos] != 0x30:
|
||||
raise ValueError("Expected varbind SEQUENCE")
|
||||
pos += 1
|
||||
vb_len, pos = _read_ber_length(data, pos)
|
||||
assert data[pos] == 0x06 # nosec B101
|
||||
if data[pos] != 0x06:
|
||||
raise ValueError("Expected Object Identifier")
|
||||
pos += 1
|
||||
oid_len, pos = _read_ber_length(data, pos)
|
||||
oid = _decode_oid(data[pos:pos + oid_len])
|
||||
@@ -169,14 +235,14 @@ class SNMPProtocol(asyncio.DatagramProtocol):
|
||||
response = _build_response(version, community, request_id, oids)
|
||||
self._transport.sendto(response, addr)
|
||||
except Exception as e:
|
||||
_log("parse_error", src=addr[0], error=str(e), data=data[:64].hex())
|
||||
_log("parse_error", severity=4, src=addr[0], error=str(e), data=data[:64].hex())
|
||||
|
||||
def error_received(self, exc):
|
||||
pass
|
||||
|
||||
|
||||
async def main():
|
||||
_log("startup", msg=f"SNMP server starting as {NODE_NAME}")
|
||||
_log("startup", msg=f"SNMP server starting as {NODE_NAME} with archetype {SNMP_ARCHETYPE}")
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, _ = await loop.create_datagram_endpoint(
|
||||
SNMPProtocol, local_addr=("0.0.0.0", 161) # nosec B104
|
||||
|
||||
89
templates/tftp/decnet_logging.py
Normal file
89
templates/tftp/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
89
templates/vnc/decnet_logging.py
Normal file
89
templates/vnc/decnet_logging.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared RFC 5424 syslog helper for DECNET service templates.
|
||||
|
||||
Services call syslog_line() to format an RFC 5424 message, then
|
||||
write_syslog_file() to emit it to stdout — Docker captures it, and the
|
||||
host-side collector streams it into the log file.
|
||||
|
||||
RFC 5424 structure:
|
||||
<PRI>1 TIMESTAMP HOSTNAME APP-NAME PROCID MSGID [SD-ELEMENT] MSG
|
||||
|
||||
Facility: local0 (16), PEN for SD element ID: decnet@55555
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
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
|
||||
|
||||
# ─── 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}"
|
||||
|
||||
|
||||
def write_syslog_file(line: str) -> None:
|
||||
"""Emit a syslog line to stdout for Docker log capture."""
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def forward_syslog(line: str, log_target: str) -> None:
|
||||
"""No-op stub. TCP forwarding is now handled by rsyslog, not by service containers."""
|
||||
pass
|
||||
Reference in New Issue
Block a user