mirror of
https://github.com/esphome/esphome.git
synced 2026-09-27 15:00:24 +00:00
[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:
co-authored by
Clyde Stubbs
parent
7a27d5dde1
commit
5764a72f1b
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
@@ -41,3 +41,4 @@ syslog:
|
||||
level: DEBUG
|
||||
strip: true
|
||||
facility: 16
|
||||
format: SYSLOG_FORMAT_PLACEHOLDER
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user