[syslog] Add configurable RFC 3164 and RFC 5424 formats (#18993)

Co-authored-by: Clyde Stubbs <2366188+clydebarrow@users.noreply.github.com>
This commit is contained in:
Iago Veiga
2026-09-22 07:26:06 +10:00
committed by GitHub
co-authored by Clyde Stubbs
parent 7a27d5dde1
commit 5764a72f1b
6 changed files with 79 additions and 24 deletions
+11 -1
View File
@@ -4,7 +4,7 @@ from esphome.components.logger import LOG_LEVELS, is_log_level, request_log_list
from esphome.components.time import RealTimeClock
from esphome.components.udp import CONF_UDP_ID
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID
from esphome.const import CONF_FORMAT, CONF_ID, CONF_LEVEL, CONF_PORT, CONF_TIME_ID
from esphome.cpp_types import Component, Parented
from esphome.types import ConfigType
@@ -14,6 +14,12 @@ DEPENDENCIES = ["udp", "logger", "time"]
syslog_ns = cg.esphome_ns.namespace("syslog")
Syslog = syslog_ns.class_("Syslog", Component, Parented.template(udp.UDPComponent))
SyslogFormat = syslog_ns.enum("SyslogFormat")
SYSLOG_FORMATS = {
"RFC3164": SyslogFormat.SYSLOG_FORMAT_RFC3164,
"RFC5424": SyslogFormat.SYSLOG_FORMAT_RFC5424,
}
CONF_STRIP = "strip"
CONF_FACILITY = "facility"
@@ -25,6 +31,9 @@ CONFIG_SCHEMA = udp.UDP_SCHEMA.extend(
cv.Optional(CONF_LEVEL, default="DEBUG"): is_log_level,
cv.Optional(CONF_STRIP, default=True): cv.boolean,
cv.Optional(CONF_FACILITY, default=16): cv.int_range(0, 23),
cv.Optional(CONF_FORMAT, default="RFC3164"): cv.enum(
SYSLOG_FORMATS, upper=True
),
}
)
@@ -41,3 +50,4 @@ async def to_code(config: ConfigType) -> None:
await cg.register_parented(var, parent)
cg.add(var.set_strip(config[CONF_STRIP]))
cg.add(var.set_facility(config[CONF_FACILITY]))
cg.add(var.set_format(config[CONF_FORMAT]))
+24 -14
View File
@@ -49,27 +49,37 @@ void Syslog::log_(const int level, const char *tag, const char *message, size_t
// Build syslog packet on stack (508 bytes chosen as practical limit for syslog over UDP)
char packet[508];
size_t offset = 0;
size_t remaining = sizeof(packet);
// Write PRI - abort if this fails as packet would be malformed
offset = buf_append_printf(packet, sizeof(packet), 0, "<%d>", pri);
if (offset == 0) {
return; // PRI always produces at least "<0>" (3 chars), so 0 means error
}
remaining -= offset;
// Write timestamp directly into packet (RFC 5424: use "-" if time not valid or strftime fails)
auto now = this->time_->now();
size_t ts_written = now.is_valid() ? now.strftime(packet + offset, remaining, "%b %e %H:%M:%S") : 0;
if (ts_written > 0) {
offset += ts_written;
} else if (remaining > 0) {
packet[offset++] = '-';
}
if (this->format_ == SYSLOG_FORMAT_RFC5424) {
offset = buf_append_str(packet, sizeof(packet), offset, "1 ");
// Write hostname, tag, and message
offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s: %.*s", App.get_name().c_str(), tag, (int) len,
message);
char timestamp[32];
size_t timestamp_len = now.is_valid() ? now.strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%S%z") : 0;
if (timestamp_len == 24) {
// ESPTime formats the numeric offset as +HHMM. RFC 3339 requires +HH:MM.
timestamp[25] = '\0';
timestamp[24] = timestamp[23];
timestamp[23] = timestamp[22];
timestamp[22] = ':';
offset = buf_append_printf(packet, sizeof(packet), offset, "%s", timestamp);
} else {
offset = buf_append_str(packet, sizeof(packet), offset, "-");
}
offset = buf_append_printf(packet, sizeof(packet), offset, " %s %s - - - %.*s", App.get_name().c_str(), tag,
(int) len, message);
} else {
// RFC 3164 has no NILVALUE. If the clock is invalid, omit TIMESTAMP so a relay can add it.
if (now.is_valid()) {
offset += now.strftime(packet + offset, sizeof(packet) - offset, "%b %e %H:%M:%S ");
}
offset = buf_append_printf(packet, sizeof(packet), offset, "%s %s: %.*s", App.get_name().c_str(), tag, (int) len,
message);
}
// Clamp to exclude null terminator position if buffer was filled
if (offset >= sizeof(packet)) {
offset = sizeof(packet) - 1;
@@ -7,6 +7,11 @@
#ifdef USE_NETWORK
namespace esphome::syslog {
enum SyslogFormat : uint8_t {
SYSLOG_FORMAT_RFC3164,
SYSLOG_FORMAT_RFC5424,
};
class Syslog final : public Component, public Parented<udp::UDPComponent> {
public:
Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {}
@@ -14,6 +19,7 @@ class Syslog final : public Component, public Parented<udp::UDPComponent> {
void on_log(uint8_t level, const char *tag, const char *message, size_t message_len);
void set_strip(bool strip) { this->strip_ = strip; }
void set_facility(int facility) { this->facility_ = facility; }
void set_format(SyslogFormat format) { this->format_ = format; }
protected:
int log_level_;
@@ -21,6 +27,7 @@ class Syslog final : public Component, public Parented<udp::UDPComponent> {
time::RealTimeClock *time_;
bool strip_{true};
int facility_{16};
SyslogFormat format_{SYSLOG_FORMAT_RFC3164};
};
} // namespace esphome::syslog
#endif
@@ -0,0 +1,8 @@
<<: !include common.yaml
syslog:
port: 514
strip: true
level: info
facility: 16
format: RFC5424
+1
View File
@@ -41,3 +41,4 @@ syslog:
level: DEBUG
strip: true
facility: 16
format: SYSLOG_FORMAT_PLACEHOLDER
+28 -9
View File
@@ -31,14 +31,25 @@ class ParsedSyslogMessage(TypedDict):
# RFC 3164 syslog message pattern:
# <PRI>TIMESTAMP HOSTNAME TAG: MESSAGE
# Example: <134>Dec 20 14:30:45 syslog-test app: [D][app:029]: Running...
SYSLOG_PATTERN = re.compile(
RFC3164_PATTERN = re.compile(
r"<(\d+)>" # PRI (priority = facility * 8 + severity)
r"(\S+ +\d+ \d+:\d+:\d+|-)" # TIMESTAMP (BSD-style "%b %e %H:%M:%S", e.g. "Dec 20 14:30:45", or NILVALUE "-")
r" (\S+)" # HOSTNAME
r"(?:(\S+ +\d+ \d+:\d+:\d+) )?" # Optional BSD TIMESTAMP
r"(\S+)" # HOSTNAME
r" (\S+):" # TAG
r" (.*)" # MESSAGE
)
# RFC 5424 syslog message pattern:
# <PRI>VERSION TIMESTAMP HOSTNAME APP-NAME PROCID MSGID STRUCTURED-DATA MSG
RFC5424_PATTERN = re.compile(
r"<(\d+)>1 "
r"(\S+) "
r"(\S+) "
r"(\S+) "
r"- - - "
r"(.*)"
)
@dataclass
class SyslogReceiver:
@@ -123,9 +134,10 @@ async def syslog_udp_listener() -> AsyncGenerator[tuple[int, SyslogReceiver]]:
sock.close()
def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
def parse_syslog_message(msg: str, format_: str) -> ParsedSyslogMessage | None:
"""Parse a syslog message and return its components."""
match = SYSLOG_PATTERN.match(msg)
pattern = RFC3164_PATTERN if format_ == "RFC3164" else RFC5424_PATTERN
match = pattern.fullmatch(msg)
if not match:
return None
pri, timestamp, hostname, tag, message = match.groups()
@@ -137,7 +149,7 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
pri=pri_val,
facility=facility,
severity=severity,
timestamp=timestamp,
timestamp=timestamp or "",
hostname=hostname,
tag=tag,
message=message,
@@ -145,15 +157,18 @@ def parse_syslog_message(msg: str) -> ParsedSyslogMessage | None:
@pytest.mark.asyncio
@pytest.mark.parametrize("format_", ["RFC3164", "RFC5424"])
async def test_syslog(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
format_: str,
) -> None:
"""Test syslog component sends properly formatted messages."""
async with syslog_udp_listener() as (udp_port, receiver):
# Replace the placeholder port in the config
config = yaml_config.replace("SYSLOG_PORT_PLACEHOLDER", str(udp_port))
config = config.replace("SYSLOG_FORMAT_PLACEHOLDER", format_)
async with run_compiled(config), api_client_connected() as client:
# Verify device is running
@@ -176,7 +191,7 @@ async def test_syslog(
# Parse and validate all messages
parsed_messages: list[ParsedSyslogMessage] = []
for msg in receiver.messages:
parsed = parse_syslog_message(msg)
parsed = parse_syslog_message(msg, format_)
if parsed:
parsed_messages.append(parsed)
@@ -204,12 +219,16 @@ async def test_syslog(
f"Unexpected hostname: {parsed['hostname']}"
)
# Validate timestamp format (BSD or NILVALUE)
if parsed["timestamp"] != "-":
if format_ == "RFC3164" and parsed["timestamp"]:
assert re.match(
r"[A-Z][a-z]{2} +\d+ \d{2}:\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
elif format_ == "RFC5424" and parsed["timestamp"] != "-":
assert re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}",
parsed["timestamp"],
), f"Invalid timestamp format: {parsed['timestamp']}"
# Verify we see different severity levels in the logs
severities_seen = {p["severity"] for p in parsed_messages}