Building up the protection you need¶
Neither the EU Cyber Resilience Act (CRA) nor IEC 62443 mandates syslog; instead they state capabilities - auditable events, integrity, confidentiality, availability, non-repudiation - and leave the realisation to you. Both judge the product you ship rather than a library inside it, as compliance in one page explains, so two devices running this library can need very different things from it.
This page is a path, not a design to copy. Start from the system you already have, add one capability at a time, and stop where your threat model, your requirements and your resources say to stop.
The worked integration¶
Every code snippet and every figure below comes from a worked integration, published in full for this release:
- solid-syslog-example - consumed with CMake.
- solid-syslog-example-make - the same integration, consumed with Make.
The baseline is constructed to carry the third-party platform code a real-world device would already have - an RTOS, a network stack, and cryptography. A device that needs SolidSyslog will already have a network stack, and if its threat analysis calls for syslog over TLS it is likely to need TLS for other protocols too. The costs stated are the costs of integrating SolidSyslog with that baseline.
The baseline runs FreeRTOS on a QEMU Cortex-M3 with lwIP, FatFs and Mbed TLS, built as C17, and holds a mutual-TLS session to a broker for as long as it runs. Its credential store already holds what commissioning gave it: its own client certificate, the keys for its storage, and the fingerprint of the site's log collector. The integration is coded twice - once with CMake, once with Make - so the build process is shown in detail either way.
The same integration path applies to any combination of platforms. Every platform dependency - network stack, TLS library, filesystem, OS primitives, clock - is injected, so you bring what you already run.
Read the figures as indications. They are measured on the example device, every stage built and run in one sweep against the same baseline and toolchain, and rounded here. What a stage costs on yours depends on your components, your configuration and tuning, your compiler and its options, and what your application already links. The example repositories carry the exact figures, every stage's run report, the diff that produced each, and the reasoning behind it.
Rounded stage by stage, the figures do not sum exactly to the cost of the whole path. Measured cumulatively against the same baseline, the path costs about 23.9 KB of flash and 38.1 KB of RAM, most of the RAM being the second TLS session stage 13 opens. Take a stage's figure as the cost of that step and the example's own measurements as the cost of the path.
How to read this¶
The order of the stages is a logical way to integrate syslog in small steps, each one adding further capability and security. It is not prescriptive, however: you may choose to skip UDP and go straight to TCP, or integrate structured data earlier or later.
The first three stages are needed by every integration. From stage 4 on, each closes with the question that decides whether you need it, and every stage says what it costs. That cost includes the application code, the SolidSyslog components, and the incremental cost within platforms already integrated. When TLS is added, for instance, we include the cost of an additional TLS connection rather than the cost of the whole TLS library.
Stage 1: Link it in¶
This stage looks only at how to link SolidSyslog into your application, without calling any of it. There are no resource costs; it is broken out for clarity.
| CMake | Makefile | IDE / manifest |
|---|---|---|
Name your platforms in SOLIDSYSLOG_PLATFORMS, then link SolidSyslog plus a SolidSyslog::<Platform> target for each header-configured upstream |
Set SOLIDSYSLOG_PLATFORMS and include solidsyslog.mk; it hands back source lists and include sets, and defines no rules |
Configure with your platform list and build the manifest target; paste the generated file and include lists into your project |
For now you need only the core and a network platform.
Stage 2: Make failures visible¶
Install the error handler with
SolidSyslog_SetErrorHandler
before any other call into SolidSyslog. The handler reports many misconfiguration
errors, and can save significant time whilst integrating.
This is not only an integration aid. The handler is the seam into your product's own error and health reporting, and it stays valuable at run time: the sender raises an edge-triggered warning when the collector becomes unreachable and a notice when delivery recovers, and the ring buffer raises a warning when it starts losing records. Route it wherever your device already routes faults, but not back into SolidSyslog: the handler contract says why, and what to do instead.
Every axis of an event has a name, the one this documentation uses, so a handler can print a diagnostic without a table of its own:
static const char* OrDash(const char* name)
{
return (name != NULL) ? name : "-";
}
static void OnSyslogError(void* context, const struct SolidSyslogErrorEvent* event)
{
(void) context;
(void) printf(
"[syslog] %s %s %s %s (%ld)",
OrDash(SolidSyslogSeverity_Name((int32_t) event->Severity)),
OrDash((event->Source != NULL) ? event->Source->Name : NULL),
OrDash(SolidSyslogErrorCategory_Name(event->Category)),
OrDash(SolidSyslogErrorEvent_DetailName(event)),
(long) event->Detail
);
if (event->HasNativeCode)
{
(void) printf(" native %ld", (long) event->NativeCode);
}
(void) printf("\n");
}
Each name function answers NULL for a value it does not know: a category in the
integrator range, a severity off the ladder, or a detail outside its source's
vocabulary. SolidSyslogErrorEvent_DetailName goes through the event's source, so it
is right whichever class spoke. Each name is bound to its enumerator, not its position,
so a code added in a later release cannot shift the others.
Where the platform has a code of its own for the fault - an errno, an FRESULT - the
event carries it in NativeCode. That code has no name, and 0 can be a real one, so
read it only when HasNativeCode is set.
For more detail see error handling and severity.
Cost. Flash ~1.7k, RAM ~10 B, for the handler shown. The category and severity names are linked only when called, and this handler calls both. Each class brings its own detail names whether or not the handler asks for them, so those arrive in the figures of the stages that link the classes.
Stage 3: Create the logger, wired to nothing¶
Create the logger with both collaborators absent, deliberately, and read what the handler prints.
struct SolidSyslogConfig config = {
.Buffer = NULL,
.Sender = NULL,
};
struct SolidSyslog* logger = SolidSyslog_Create(&config);
No <Class>_Create fails or returns NULL - a missing collaborator is substituted with
its Null object and reported - so the only evidence is what the handler says:
[syslog] CRITICAL SolidSyslog BAD_CONFIG CREATE_NULL_BUFFER (1)
[syslog] CRITICAL SolidSyslog BAD_CONFIG CREATE_NULL_SENDER (2)
[syslog] CRITICAL SolidSyslog BAD_CONFIG CREATE_NULL_STORE (3)
The order matters. Wired all at once, a logger whose handler prints nothing cannot be told apart from one whose faults are never reported. Seeing these three faults first, and each one stop as its collaborator is wired in, shows that the handler is reached and that each collaborator was accepted.
A convention worth adopting now: NULL as a parameter means "not supplied" and is
reported, while a collaborator you have deliberately done without is passed as its Null
object. The library distinguishes the two, and so should anyone reading your wiring
later.
Cost. Flash ~1.3k, RAM ~1.0k. Half the RAM is the record slot the logger keeps
for the record SolidSyslog_Service is dealing with, SOLIDSYSLOG_MAX_MESSAGE_SIZE bytes;
most of the rest is the library's table of the events it has yet to report.
Stage 21 fits the cap to the device.
Stage 4: A first record on the wire¶
This is the simplest configuration that sends a syslog message. Constructing the logger
with SolidSyslog_Create
happens once, typically during device startup. Using SolidSyslogPassthroughBuffer with
a SolidSyslogUdpSender means that when
SolidSyslog_Log sends a message, it
is formatted immediately and passed straight to the SolidSyslogUdpSender, which sends
it over UDP from the same task.
struct SolidSyslogUdpSenderConfig senderConfig = {
.Resolver = SolidSyslogLwipRawResolver_Create(),
.Datagram = SolidSyslogLwipRawDatagram_Create(NULL),
.Address = SolidSyslogLwipRawAddress_Create(),
.Endpoint = CollectorEndpoint,
};
struct SolidSyslogSender* sender = SolidSyslogUdpSender_Create(&senderConfig);
struct SolidSyslogConfig config = {
.Buffer = SolidSyslogPassthroughBuffer_Create(sender),
.Sender = sender,
.Store = SolidSyslogNullStore_Get(),
};
struct SolidSyslog* logger = SolidSyslog_Create(&config);
The logging itself is done as shown below. In this configuration SolidSyslog is not
reentrant, so call it from one task only - and call
SolidSyslog_Service from that
same task, after each SolidSyslog_Log at the least. There is nothing to drain, but
Service is where the records the logger writes about itself go
out: a send that failed is reported there, and the report is held while sends still
fail and delivered once one succeeds. A call from the main loop as well lands that
report when the path is back rather than at the device's next record.
struct SolidSyslogMessage message = {
.Facility = SOLIDSYSLOG_FACILITY_LOCAL0,
.Severity = SOLIDSYSLOG_SEVERITY_INFORMATIONAL,
.MessageId = "BOOT",
.Msg = "device started",
};
SolidSyslog_Log(logger, &message);
The store stays the Null object while the buffer is a passthrough: the buffer hands each
record to the sender as it is logged, so a store behind it would hold the logger's reports
and nothing the application logged. Stage 10 adds the store behind the ring from stage 7;
a device that stays on one task but wants retention wires that ring one record deep over
SolidSyslogNullMutex and services it the same way.
What arrives is a valid RFC 5424 record any collector will parse:
Timestamp, hostname, app-name and process-id are the RFC's nil value. The record is valid without them; filling them in is the next stage, with a cost of its own.
The datagram's config states the largest IPv4 payload the path carries, which lwIP
cannot discover for itself. NULL leaves it at SOLIDSYSLOG_UDP_IPV4_PAYLOAD_LIMIT,
480 octets by default, the size RFC 5426 section 3.2 requires every receiver to accept;
the example's records stay under it.
Most of the RAM is stack on the logging task. The record is built on the stack of
whichever task calls SolidSyslog_Log, sized by SOLIDSYSLOG_MAX_MESSAGE_SIZE, so that
task has to be deep enough to hold one. In the example the logging task was still at the
RTOS minimum inherited from the baseline and had to grow. Where several tasks log, the
shared scratch builds the record in one static
buffer under a mutex instead, so the cost is paid once rather than on every task's stack.
Stage 21 fits the cap and the stacks to the device, once every collaborator is in place
and the high-water marks are worth trusting.
When you need it. Every device needs this much. The question is whether UDP is enough: it drops records silently, and anyone on the path can read them. Stage 8 answers the first, stage 13 the second - treat UDP as a stepping stone to whichever your threat model needs.
Cost. Flash ~4.2k, RAM ~1.7k, nearly all of it stack depth on the logging task.
Stage 5: Name it and time it¶
The record so far carries no timestamp and no device name. Fill the RFC 5424 header fields from what the device already has: the clock it can read, the address on its interface, and its own name.
struct SolidSyslogConfig config = {
/* ... */
.Clock = SyslogFields_Clock,
.GetHostname = SyslogFields_Hostname,
.GetAppName = SyslogFields_AppName,
};
Two details are worth getting right. Zero the timestamp struct before filling it, so a clock that cannot answer fails the library's validation and is emitted as the nil value rather than as a wrong time. And where a device has no resolvable name, RFC 5424 section 6.2.4 allows its address in the HOSTNAME field instead.
PROCID stays nil here - a bare-metal image has no process to identify - and so does STRUCTURED-DATA, until the next stage.
When you need it. As soon as more than one device reports to the collector, or a record's time will be relied on. Everything the later stages add - a sequence number, the clock's quality, the device's own identity - builds on these fields rather than replacing them.
Cost. Flash ~400 B, no RAM.
Stage 6: Number the records¶
Add the first structured-data element,
SolidSyslogMetaSd, carrying a sequence number. Elements
are supplied to the logger as an array and read on every record, so the elements and the
array must outlive the logger, not just the call that creates it.
struct SolidSyslogMetaSdConfig metaConfig = {
.Counter = SolidSyslogStdAtomicCounter_Create(),
};
sd[0] = SolidSyslogMetaSd_Create(&metaConfig);
struct SolidSyslogConfig config = {
/* ... */
.Sd = sd,
.SdCount = 1U,
};
The sequence number is incremented once per record formatted, not once per record delivered. A record that never arrives therefore leaves a gap in the sequence rather than no trace at all, which is why it is worth adding before any buffering or storage that could drop one.
Unlike a header field, an SD PARAM has no nil value: one that is unset is omitted
entirely rather than written as -.
The example uses SolidSyslogStdAtomicCounter. If your toolchain has no atomics you
must supply your own counter, observing the initialisation and roll-over constraints RFC
5424 section 7.3.1 places on the field: it starts at one and never reports zero. If you log
from more than one task, that counter must increment atomically.
The counter comes from C11 atomics, so add its platform to the list from stage 1.
When you need it. If anyone needs to know that records have gone missing.
Cost. Flash ~1.0k, RAM ~65 B.
Stage 7: Decouple logging from sending¶
A SolidSyslogCircularBuffer between
SolidSyslog_Log and the sender, drained by a service task calling
SolidSyslog_Service.
SolidSyslog_Log formats, enqueues and returns; the service task does the I/O.
static uint8_t s_ring[SOLIDSYSLOG_CIRCULAR_BUFFER_RING_BYTES(SYSLOG_BUFFER_RECORDS)];
.Buffer = SolidSyslogCircularBuffer_Create(SolidSyslogFreeRtosMutex_Create(), s_ring, sizeof(s_ring), NULL, NULL),
This separates logging an event from sending it.
SolidSyslog_Log becomes safe to call from any number of tasks, and potentially cheap
enough to call from the place the event actually happens rather than from somewhere
convenient later. Nothing that logs waits on the network.
A ring that fills applies the discard policy its last two arguments inject. NULL
applies Oldest, which drops the oldest records to make room, and stage 20 makes the
policy the device's own. The buffer reports once when a run of losses starts, and again
only after a write has lost nothing since; the sequence number from stage 6 says how
many went. The store from stage 10 reports loss under the same rules, stated once under
when a record is lost. From this stage on,
the service task also writes that report into the trail as a
record of the logger's own, on by default; that page states what
it costs and how a device turns it off.
When you need it. Once logging and sending are decoupled, the buffer has to absorb however many events can be logged before it is next serviced. It also makes logging from multiple tasks safe.
Cost. Flash ~2.5k, RAM ~5.5k - the ring and the service task's stack. That stack
holds the send beneath SolidSyslog_Service but not the record being sent, which lives
in the logger and was counted at stage 3. You choose the ring size, and stage 21
revisits it once the store is there to hold a backlog.
The mutex comes from your RTOS, so add its platform to the list from stage 1.
Stage 8: Detect loss where it happens¶
UDP to TCP, by putting a SolidSyslogStreamSender
over a TCP stream. The network retransmits rather than dropping, and a send fails once
the stack knows the collector is gone, instead of appearing to succeed. Records are
framed by octet count per RFC 6587.
struct SolidSyslogStreamSenderConfig senderConfig = {
.Resolver = SolidSyslogLwipRawResolver_Create(),
.Stream = SolidSyslogLwipRawTcpStream_Create(&tcpConfig),
.Address = SolidSyslogLwipRawAddress_Create(),
.Endpoint = CollectorEndpoint,
};
struct SolidSyslogSender* sender = SolidSyslogStreamSender_Create(&senderConfig);
With stage 6 this completes loss detection: the transport reports the losses it observes, and the sequence number reveals afterwards anything the transport could not. It is also what makes the delivery-failed and delivery-restored events from stage 2 meaningful.
When you need it. If the device must know that delivery is failing - to raise an alarm, to fall back, to start storing. Over UDP it never finds out.
Cost. Flash ~900 B, RAM ~210 B.
RFC 6587 is Historic, and the IESG recommends TLS (stage 13) over plain TCP for new deployments. Plain TCP is here for collectors you do not control, and as the step that gives storage somewhere to spool before cryptography arrives.
Stage 9: Say how far to trust the clock¶
Add SolidSyslogTimeQualitySd, and give MetaSd
an uptime source alongside its counter.
struct SolidSyslogMetaSdConfig metaConfig = {
.Counter = SolidSyslogStdAtomicCounter_Create(),
.GetSysUpTime = SolidSyslogFreeRtos_GetSysUpTime, /* new */
};
sd[1] = SolidSyslogTimeQualitySd_Create(SyslogTimeQuality);
Time quality states how far the clock can be trusted, which matters when comparing events from different devices. Ideally every device on the network is time-synchronised; this element tells the collector whether that is so, and how closely. The uptime alongside it distinguishes a reboot from a counter wrap when the sequence restarts at one.
It is added here, immediately before the store. Until now a record reached the collector shortly after it was raised, so its timestamp and its arrival time were much the same. Store-and-forward breaks that: a record can arrive hours later. The device should say what its clock is actually worth before that assumption goes.
When you need it. If events from this device will be ordered against events from others, or if a record's timestamp will be relied on after a delay.
Cost. Flash ~400 B, RAM ~30 B.
Stage 10: Survive an outage¶
Spool to a SolidSyslogBlockStore. The service task
drains the ring into storage and sends from there, so a failed send costs a retry rather
than the record.
struct SolidSyslogBlockStoreConfig storeConfig = {
.BlockDevice = SolidSyslogFileBlockDevice_Create(SolidSyslogFatFsFile_Create(), SYSLOG_STORE_PREFIX, 0U),
.MaxBlocks = SYSLOG_STORE_BLOCKS,
.SecurityPolicy = SolidSyslogCrc16Policy_Create(),
};
.Store = SolidSyslogBlockStore_Create(&storeConfig),
Three decisions come with it: how much you can store, which is capacity on the medium rather than RAM; what happens when it fills - discard oldest, discard newest, or halt; and whether you want warning before that point, via the capacity threshold, which raises an event at each crossing and calls a callback if you set one. Left unset, as the ring's was, the discard policy is Oldest; stage 20 makes it the device's choice. The store also holds the records the logger writes about itself, so size it with those in mind.
What the store loses it reports as the buffer does: a run of records turned away or evicted unsent raises one warning when it starts. A medium that fails is different in kind. The store reports it once and never uses the device again, and delivery continues without storage, so route that event somewhere it will be acted on.
The store opens its device when it is created, so create it once the volume is mounted, and the logger after it, since the logger's config carries the store. A store created before the mount stops using the device at once, and because records still reach the collector, the only signs are what the handler says and the two records the logger writes about it on its first service pass. The file layer reports the open that failed, with the filesystem's own code, and the store then reports that it has frozen:
[syslog] ERROR FatFsFile FILE_IO_FAILED OPEN_FAILED (4) native 12
[syslog] ERROR BlockStore STORE_FROZEN OPEN_FAILED (4)
The example met it here: it created the logger before starting the scheduler and mounts its volume from a task, so creating the logger moved to that task, after the mount.
The file layer comes from your filesystem, so add its platform to the list from stage 1.
The checksum here is a checksum, not tamper-evidence. It catches a truncated write or bit-rot; anyone who can edit a stored record can recompute it. What it buys is knowing a record came back the way it went in, which is the prerequisite for spooling at all. Making stored records tamper-evident is stage 15.
When you need it. What is your audit-loss budget? If the collector is unreachable for an hour, is losing that hour acceptable? Must records survive a reboot?
Cost. Flash ~5.9k, RAM ~1.5k - handles and one record buffer, not capacity. Upstream: files on your filesystem. Both the number of files and the size of each are tunable.
Stage 11: Say who sent it¶
Name the device in the record with
SolidSyslogOriginSd - the software, its version, and
your enterprise number.
struct SolidSyslogOriginSdConfig originConfig = {
.Software = SYSLOG_SOFTWARE,
.SwVersion = SYSLOG_SW_VERSION,
.EnterpriseId = SYSLOG_ENTERPRISE_ID,
};
sd[2] = SolidSyslogOriginSd_Create(&originConfig);
SwVersion is your product's version, not SolidSyslog's. Take it from wherever your
build already keeps it; the example passes in its CMake project() version.
It is added after storage. While records went straight out, "who sent this" was implied by the connection they arrived on. Once records can replay hours later that stops being true, and the record has to say so itself.
When you need it. If records will be correlated across devices, replayed after a delay, or relayed through anything.
Cost. Flash ~400 B, RAM ~45 B.
Enterprise number 32473 is reserved by RFC 5612 for documentation. A shipping product must use its own enterprise number, registered with IANA.
Stage 12: State the device's own address¶
Add the ip PARAM to the origin element, sourced from the same interface address the
HOSTNAME field reports.
struct SolidSyslogOriginSdConfig originConfig = {
/* ... as stage 11 ... */
.GetIpCount = SyslogOriginIpCount,
.GetIpAt = SyslogOriginIpAt,
};
A relay or NAT between the device and the collector rewrites the address the collector
observes. ip is what the device says about itself, and that survives the hop. The
PARAM is repeatable per RFC 5424 section 7.2, so the library asks for a count and then one
value per index rather than taking a single string.
When you need it. If anything sits between the device and the collector - a relay, a gateway, or NAT - and the source address the collector sees can no longer be trusted to identify the device.
Cost. Flash ~110 B, no RAM.
Stage 13: Trust the channel¶
Wrap the byte stream in TLS - here
SolidSyslogMbedTlsStream, layered over the TCP
stream from stage 8 - and authorise the collector by the fingerprint of its certificate.
Records can then be read only by that collector, and cannot be altered in transit. The
collector is authenticated to the device; the device is not yet authenticated to the
collector. The stream comes from your TLS library, so add its platform to the list from
stage 1.
The fingerprint was provisioned at commissioning, alongside the device's other credentials. No CA is involved, so a site with no PKI can run it, and RFC 5425 requires that a peer can be authorised this way - see accepting a peer by its fingerprint for the form and what is checked.
static const char* s_collectorPins[1];
static void CollectorProfile(struct SolidSyslogMbedTlsProfile* profile, void* context)
{
(void) context;
profile->ServerName = SYSLOG_COLLECTOR_HOST;
}
s_collectorPins[0] = DeviceCertStore_CollectorPin();
struct SolidSyslogMbedTlsHandleCredentialsConfig credentialsConfig = {
.Rng = DeviceCertStore_Rng(),
.PeerFingerprints = s_collectorPins,
.PeerFingerprintCount = 1U,
};
struct SolidSyslogMbedTlsStreamConfig tlsConfig = {
.Transport = SolidSyslogLwipRawTcpStream_Create(&tcpConfig),
.Sleep = SyslogSleep,
.Rng = DeviceCertStore_Rng(),
.Credentials = SolidSyslogMbedTlsHandleCredentials_Create(&credentialsConfig),
.Profile = CollectorProfile,
};
.Stream = SolidSyslogMbedTlsStream_Create(&tlsConfig),
Two things decide trust. The pin is read from the device's credential store, never computed from the certificate the collector presents: a pin is something the device was told, and one derived from what it is shown authorises nothing. The profile is asked at every connection and names the peer, so the name is checked as well - the pin says which certificate, the name says which peer it was issued to.
A pinned certificate is still checked against its validity dates. The pin does not extend them, and a renewed certificate has a new fingerprint, which is what stage 14 deals with.
When you need it. If the log path crosses a network you do not control, or if someone reading records in transit would learn something they should not. Also if the device needs to know it is talking to the real collector rather than to whatever answered on that address. On a control network the collector is often a single fixed host with no site PKI behind it, and a fingerprint provisioned at commissioning needs none - which is why this path pins first, and adds a CA chain only at stage 19, for sites that run one.
Cost. Flash ~3.4k. RAM ~28k on the example device - but only ~730 B of that is the library; the rest is a second concurrent TLS session: the Mbed TLS memory pool grows from 32 to 53 KiB, and the service task's stack grows for the handshake.
Upstream. If your device does not already run TLS, the library and its trust material will dominate everything on this page - expect an order of magnitude or more above the adapter that drives it, and read the footprint guidance for the specific TLS library you are considering, because it varies widely with the features and ciphersuites you enable. If your device already holds a TLS session for something else - a cloud connection, OTA, a vendor framework - that cost is already paid, and this stage adds the adapter and a second session. That is the case the worked integration measures, and the common one on a device with a reason to care about audit logging.
Stage 14: Cross a certificate renewal¶
A pin names one certificate, and renewing the collector's certificate changes its fingerprint. Unplanned for, the renewal stops every device that pins the collector: its records are held in the store rather than lost, but nothing is delivered until each device is provisioned again. Give the credentials two pin slots instead. Provision the renewed certificate's pin beside the current one before the collector switches, so either is accepted while it does, and retire the old one afterwards.
static const char* s_collectorPins[2];
static uint32_t s_streamVersion = 1U;
static uint32_t SyslogStreamVersion(void* context)
{
(void) context;
return s_streamVersion;
}
s_collectorPins[0] = DeviceCertStore_CollectorPin();
s_collectorPins[1] = s_collectorPins[0];
/* in the credentials and the stream config */
.PeerFingerprints = s_collectorPins,
.PeerFingerprintCount = 2U,
.Version = SyslogStreamVersion,
/* callable from any task: each only queues the change, false if the queue is full */
bool Syslog_ProvisionNextCollectorPin(const char* pin)
{
/* never queue NULL, which reads as a retirement */
return (pin != NULL) && (xQueueSend(s_pinChanges, &pin, 0U) == pdTRUE);
}
bool Syslog_RetireCollectorPin(void)
{
const char* retire = NULL;
return xQueueSend(s_pinChanges, &retire, 0U) == pdTRUE;
}
/* service task only, before each SolidSyslog_Service */
void Syslog_ApplyPinChanges(void)
{
const char* pin = NULL;
while (xQueueReceive(s_pinChanges, &pin, 0U) == pdTRUE)
{
if (pin != NULL)
{
s_collectorPins[1] = pin;
}
else
{
s_collectorPins[0] = s_collectorPins[1];
}
s_streamVersion++;
}
}
Both slots hold the current pin until a renewal is under way: a slot left NULL is
reported when the credentials are created, which then hand back their Null object. The
stream's version moves whenever the pins do; the sender checks it before every record and
reconnects when it has moved, so new pins apply without a restart.
The pins are stored as pointers rather than copies, so a pin must outlive the
credentials, as the field requires; the example's come from its credential store. And
the service task reads the version before every record and the pins at each
connection, so a change has to reach them on that task. The example queues each request
and applies it before each call to SolidSyslog_Service; making the change on the
service task directly works as well. A queued request can be refused, so check it: a
dropped provisioning leaves the device unable to reconnect after the renewal, and a
dropped retirement leaves the old pin accepted. Whoever can call these chooses which
collector the device trusts, so take a pin only from a source you would trust with
commissioning.
Crossing a renewal, the handler sees delivery fail and recover: the collector restarts
with its renewed certificate and drops the session, the record logged at that moment is
held, and the reconnect meets the renewed certificate. Without the new pin provisioned
first, every reconnect is refused with
SOLIDSYSLOG_TLS_STREAM_ERROR_PEER_FINGERPRINT_MISMATCHED until someone provisions the
device.
When you need it. If the collector's certificate will ever be renewed - which a certificate with an expiry date will be. The alternative is provisioning every device at the moment the collector switches.
Cost. Flash ~650 B, RAM ~90 B, most of the RAM being the queue. Part of the flash is the example's renewal sequence rather than the library.
Stage 15: Protect what is at rest¶
Replace the CRC-16 from stage 10 with a keyed HMAC. The checksum told you a record came back the way it went in; the HMAC tells you nobody without the key has changed it since. An edit made without the key fails verification, so stored records become tamper-evident rather than merely intact. The store discards such a record, reports it, and carries on with the ones behind it. The seal covers each record's content, not the store: what a seal covers says what it leaves undetected.
A key that is briefly unavailable is not a verdict on what is already stored: the policy reports it, and stored records wait until they can be checked. A record raised meanwhile cannot be sealed, so it is not stored; it is lost, and the store reports that.
struct SolidSyslogMbedTlsHmacSha256PolicyConfig hmacConfig = {.GetKey = SyslogStoreKey};
.SecurityPolicy = SolidSyslogMbedTlsHmacSha256Policy_Create(&hmacConfig),
Key custody, rotation and provisioning are yours; the library consumes a key you supply and never stores one. See at-rest cryptography.
When you need it. If an attacker could reach the medium - removable, unattended, or stealable - and stored records must be provably unaltered.
Cost. Flash ~460 B, RAM ~25 B. Upstream: a crypto primitive, which your TLS library already provides and has already linked if you took stage 13. Reaching for at-rest protection without TLS is where this stage carries an upstream cost of its own.
Stage 16: State the protection in force¶
A private structured-data element reports the transport in use and the at-rest policy protecting stored records:
The standard elements carry what RFC 5424 defines for any device; a private element carries what is specific to your product. A collector can use it to see the protection the device reports for each record, and to alert on a device that reports a weaker pipeline. It is the device's own statement, not proof: a relay passes it on unchanged, whatever its next hop.
Those are the values in force at this stage. They change as the remaining stages land -
transport="mtls" at stage 17, atRest="aes-256-gcm" at stage 18, and a
collectorAuth parameter at stage 19 - so derive each from the handles the device
actually holds, not from what you intended to configure. A credential that failed to load
leaves the device less protected than its configuration suggests, and an element
claiming protection that is not in force is worse than no element at all, because that
claim is exactly what a collector is watching for.
When you need it. If a collector has to know the protection a device reports for each record, rather than assume it. It is also the cheapest way to detect a fleet member whose pipeline has silently degraded.
Cost. Flash ~125 B, RAM negligible.
Stage 17: Prove which device sent it¶
Server-authenticated TLS proves the collector is genuine. It does not tell the collector which device it is talking to: any client the collector will admit can connect, and the device name in the record is a claim rather than a proof. Adding a client certificate and its key makes the handshake mutual, so the receiver authenticates the device cryptographically.
struct SolidSyslogMbedTlsHandleCredentialsConfig credentialsConfig = {
/* ... the pins, as stage 14 ... */
.ClientCertChain = DeviceCertStore_ClientChain(),
.ClientKey = DeviceCertStore_ClientKey(),
};
The collector is still authorised by its pin alone: the two directions are independent.
Both must be set. Supplying one without the other is reported on every connection, and the device then presents no certificate at all. A collector that requires one refuses the handshake; one that does not accepts a connection that is server-authenticated only, which is exactly the weakening an auditor would look for - and why the pipeline element in stage 16 reports what is actually in force rather than what was intended.
What it authenticates is the TLS peer. If the device connects to the collector directly, that is the device. If anything terminates the connection in between - a relay, a gateway, a broker - then the collector authenticates that hop, not the device behind it, and the device's identity in the record is once again a claim. TLS protects each hop; it does not carry provenance across one. On a control network with a relay between the device and the SIEM, which is a common industrial shape, this stage buys you an authenticated first hop rather than end-to-end attribution.
Where that matters, OriginSd from stages 11 and 12 is what the device says about
itself and survives the hop, and the collector's trust in it rests on the relay. The
threat model states this limitation and the related replay
exposure in full.
When you need it. When the receiver has to authenticate the device rather than take its word - a collector that requires client certificates, or a deployment where the device reaches the collector directly and origin has to be provable. It is not a free upgrade. Mutual TLS needs a certificate per device, somewhere safe to keep the private key, and an issuing and revocation process behind both. A key kept in readable flash can be copied, and a copy lets another device authenticate as this one. Where devices are already identified at another layer, or where there is no device PKI to build on, server-authenticated TLS is a reasonable place to stop.
Cost. Flash ~75 B, RAM ~2.1k. Presenting a certificate costs almost no code; the RAM is the TLS library's memory pool, grown to keep the example's margin for fragmentation once the client certificate is carried through the handshake. The example device already holds a client certificate for its own broker session, so this is the cost of using credentials, not of provisioning them - a device reaching for mutual TLS from a standing start also has to store and parse them.
Stage 18: Encrypt what is at rest¶
Replace the HMAC from stage 15 with authenticated encryption. Tamper-evidence proves a stored record was not altered; it does nothing to stop anyone reading it. Authenticated encryption does both - the body is encrypted, the record header is authenticated as associated data, and the nonce and tag travel in the trailer.
struct SolidSyslogMbedTlsAesGcmPolicyConfig gcmConfig = {.GetKey = SyslogStoreKey, .Rng = rng};
.SecurityPolicy = SolidSyslogMbedTlsAesGcmPolicy_Create(&gcmConfig),
These are separate decisions, and the second is not implied by the first. A device that only needs to prove records were not altered can stop at stage 15. The store key does not change: its name says what it protects, not which algorithm protects it, so escalating the policy needs no new key provisioned.
GCM needs a fresh nonce per record, so the policy takes the device's RNG as well as the key. That is the only wiring difference from the HMAC policy.
When you need it. If a disk that leaves the device would give something away - records naming users, addresses, process values, or anything else you would not publish.
Cost. Flash ~150 B, RAM negligible. Almost free if you took stage 13, because a device negotiating a GCM ciphersuite for TLS has already linked the same primitive.
Stage 19: Require the site's CA as well¶
For a site that runs its own PKI, also require the collector's certificate to chain to the site CA. The pin still has to match, so either failing stops delivery, and the device is under the site's certificate policy as well as its own commissioning.
struct SolidSyslogMbedTlsHandleCredentialsConfig credentialsConfig = {
/* ... as stage 17 ... */
.CaChain = DeviceCertStore_CaChain(),
};
A matching pin waives nothing. Given an anchor the collector's certificate does not chain
to, nothing is delivered and the handler reports
SOLIDSYSLOG_TLS_STREAM_ERROR_PEER_CERTIFICATE_UNTRUSTED - distinct from a fingerprint
mismatch, so the two failures can be told apart in the field. The pipeline element gains a
parameter saying which is in force, so a collector can tell a device on the pin alone from
one on both:
This is the last protection stage because it depends on the site rather than the product, and it adds a way for delivery to stop that the pin alone avoids: when the site CA or an intermediate expires or is replaced, every device holding it stops at once. A collector certificate renewed by the same CA still crosses as it did at stage 14.
When you need it. When the site runs a PKI and its policy requires every peer to chain to it. Where there is no site PKI, the pin from stage 13 is complete on its own.
Cost. Flash ~115 B, RAM negligible. The chain itself costs nothing measurable on the example: the collector's certificate is already parsed to compute its fingerprint, and the CA is one the device already holds for its broker session. The flash is the pipeline element's new parameter.
Stage 20: Decide what a full queue does¶
The ring and the store were given no discard policy, and neither was the logger, so
each applies the library's default, Oldest, which suits most devices. Give all three one
function of yours instead, a
SolidSyslogDiscardPolicyFunction. Each asks it
at the moment it has to decide, so the device chooses what a full queue gives up and can
change its answer while it runs.
static volatile enum SolidSyslogDiscardPolicy s_discardPolicy = SOLIDSYSLOG_DISCARD_POLICY_OLDEST;
static enum SolidSyslogDiscardPolicy SyslogDiscardPolicy(void* context)
{
(void) context;
return s_discardPolicy;
}
.Buffer = SolidSyslogCircularBuffer_Create(SolidSyslogFreeRtosMutex_Create(), s_ring, sizeof(s_ring), SyslogDiscardPolicy, NULL),
/* on the store */
.GetDiscardPolicy = SyslogDiscardPolicy,
/* on the logger */
.GetDiscardPolicy = SyslogDiscardPolicy,
.OnHalt = SyslogHalted,
Three places can be full. The ring asks on the task that logs; the store asks on the service task; and the logger, which holds a record for the sender between passes, asks when the ring fills behind it. One function serves all three, so the pipeline applies one policy; nothing checks that they agree.
Oldest drops the oldest records to make room, so the newest survive. Newest refuses the
record being written. Halt refuses it too, and stops the logger at the first record a
full store refuses, or when the ring has filled behind a record Service is still holding
from its last pass. The logger calls its OnHalt once, on the service task, after the
refusal has been reported and the halt raised as
SOLIDSYSLOG_CAT_LOGGER_HALTED, and moves
nothing more until your function answers otherwise or the device restarts. Whichever
policy applies, lost records are reported once when a run of losses starts, by the queue that lost them, as
when a record is lost describes.
The example stays on Oldest. Its Syslog_SetDiscardPolicy changes the answer from any
task, and the next record to meet a full queue sees it. OnHalt is the device's own
reaction: in the example, a line on the console; on a device whose records must not be
lost, where the device stops.
When you need it. If what a full queue does is for the site or the commissioning
engineer to decide rather than the firmware, or if the device must stop rather than lose
a record. A device content to keep the newest records can leave every policy argument
NULL.
Cost. Flash ~105 B, all of it the example's own code, and no RAM: the queues already carried the pointers.
Stage 21: Fit the sizes to the device¶
Every collaborator is in place, so the compile-time sizes can come down to what the device actually uses.
/* app/config/solid_syslog_tunables.h */
#define SOLIDSYSLOG_MAX_MESSAGE_SIZE 560U
#define SOLIDSYSLOG_ADDRESS_POOL_SIZE 1U
#define SOLIDSYSLOG_TCP_STREAM_POOL_SIZE 1U
#define SOLIDSYSLOG_STREAM_SENDER_POOL_SIZE 1U
The message cap comes first, because the ring, the record slot from stage 3, the store's record buffer and the formatter frame on the logging task's stack all follow it. The worst case measured on the example is 538 octets - five SD-ELEMENTs with both counters at full 32-bit width and both addresses at fifteen characters, the fifth being the library's own event element at the longest kind the device can raise, plus that event's fixed message - so 560 leaves room for longer messages on that device. Anything longer is truncated rather than dropped: a cut that falls in the message shortens it, while a cut that falls inside the structured data leaves an element unclosed, which a collector rejects. That is what the event records met under a 400-octet cap. RFC 5424 section 6.1 says a receiver should accept 2048 octets; over UDP, RFC 5426 section 3.2 guarantees only 480.
The pools follow. Their defaults suit a device running several transports at once; this one runs a single sender, over a single stream, to a single destination. Then the ring, which drops from eight records to four now that the store holds the backlog and the ring only has to absorb what can be logged while the service task is sending. The task stacks go last, at twice their measured high-water marks.
These are compile-time overrides, and each has to reach Core, the platform sources and your own code alike - see tunables, which covers the mechanism and the way it is most often got wrong.
When you need it. Once the pipeline is complete. Sizing earlier means sizing against a device that is still missing collaborators, and the high-water marks that justify a stack are only trustworthy when everything that runs on it is there.
Cost. Flash negligible. RAM ~2.6k returned.
Where to go next¶
- Adding it to your build: the build detail behind this page, plus the tunables.
- Structured data: authoring the evidence elements, and your own.
- Error handling and severity: reading the events from stage 2.
- TLS: what each TLS pack checks, including a peer authorised by its fingerprint.
- Compliance in one page: what CRA and IEC 62443 ask of an audit-logging function.
- IEC 62443 guide and the RFC compliance matrix.
- Threat model: the division of responsibility this page assumes.