From b9b5db8d2355def69b0d6a88172b8dd4fc7ae88c Mon Sep 17 00:00:00 2001 From: David Cozens Date: Tue, 28 Jul 2026 20:09:10 +0100 Subject: [PATCH 1/2] ci: stand up the broker the simulated device already talks to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device's own description says it "already speaks mTLS to other systems", but nothing in the run ever opened such a session. That leaves one number unmeasurable: how much of mbedTLS's memory SolidSyslog actually adds. A baseline whose mbedTLS buffer is sized for a session it never opens charges SolidSyslog nothing for TLS; one sized for credentials alone charges it a whole session that a real mTLS device already holds. This is the endpoint half of making that claim real. `openssl s_server` on 8883, in the oracle's network namespace so the device reaches it through the same slirp gateway, with its own certificate from the run's PKI — a different peer from the collector, so a different certificate. `-Verify` rather than `-verify`, because the lower-case form requests a client certificate and accepts a client that sent none; and `-verify_return_error`, because s_server's default is to report a verification failure and carry on, so without it "required" would not mean "trusted" — the same distinction peer-verify(required-trusted) draws on 6515. Proved before the device runs, for the same reason the four collector listeners are: a dead broker would read as "the baseline uses less memory" rather than as a failure, and nothing would catch it. `-rev` echoes each buffer back reversed, so the check is that a client presenting the device certificate got its probe back — bytes across the session, not merely a completed handshake — and that a client presenting no certificate got nothing. The authenticated probe retries for ten seconds, because depends_on guarantees the broker container started and not that s_server bound its socket. Retrying the real check rather than polling the port separately keeps one mechanism, and it avoids putting a second client in front of a server that handles one connection at a time — the device holds its session for the whole run, so a periodic healthcheck would queue behind it rather than learn anything. Two smaller changes so the next commit has somewhere to report from. The run report now carries the device's [sim] lines, which until now were printed and dropped — including the diagnostics a failed bring-up emits, so a failing run shows its reason rather than only its verdict. And the listener labels lose the word "oracle": the broker is not one, it is the device's own peer, and its cost belongs to the device. Co-Authored-By: Claude Opus 5 (1M context) --- docker/docker-compose.yml | 36 +++++++++++++++++++--- run-report.txt | 20 +++++++------ scripts/gen-certs.sh | 25 +++++++++++----- scripts/run.sh | 8 ++--- scripts/smoke-oracle.sh | 63 ++++++++++++++++++++++++++++++--------- 5 files changed, 114 insertions(+), 38 deletions(-) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 098f1ce..0a5611d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,11 +1,18 @@ # The one way to run this repo — drive it with ./run.sh (which wraps this). # -# Three containers: -# certs : generates the test PKI (one CA, a collector certificate, a device -# certificate) into build/certs. Runs to completion before the -# oracle starts, because the oracle reads its certificate at boot. +# Four containers: +# certs : generates the test PKI (one CA, a collector certificate, a broker +# certificate, a device certificate) into build/certs. Runs to +# completion before anything else starts, because both servers read +# their certificate at boot. # syslog-ng : the collector oracle (the same balabit image the BDD harness uses). # Listens on UDP, TCP and TLS/mTLS from the Baseline onwards. +# broker : the system the simulated device already talks to. Nothing to do +# with syslog — it stands in for the cloud or broker connection a +# secure device holds anyway, so that the mTLS the baseline claims +# to speak is a session it really opens. Its cost is therefore in +# the baseline, and SolidSyslog is charged only what a second +# concurrent session adds. # run : the cross image (arm-none-eabi-gcc + qemu-system-arm). It shares # syslog-ng's network namespace, so the app's QEMU slirp gateway # (10.0.2.2) reaches the oracle exactly as it will from Minimal. At the Baseline the @@ -33,6 +40,26 @@ services: certs: condition: service_completed_successfully + # -rev echoes each buffer back reversed, which is what lets both the smoke check + # and the device prove data crossed the session rather than only that the + # handshake completed. -Verify (capital V) requires the client certificate; the + # lower-case form would request one and accept a client that sent none. And + # -verify_return_error, because s_server's default is to report a verification + # failure and carry on — required is not the same as trusted, which is the + # distinction the collector's own mTLS port already turns on. + broker: + image: *cross-image + network_mode: "service:syslog-ng" + volumes: + - ../build/certs:/certs:ro + depends_on: + certs: + condition: service_completed_successfully + command: > + openssl s_server -accept 8883 -rev -quiet + -cert /certs/broker.crt -key /certs/broker.key + -CAfile /certs/ca.crt -Verify 1 -verify_return_error + run: image: *cross-image # Root so it can write the bind-mounted workspace on a Linux CI runner (the @@ -41,6 +68,7 @@ services: network_mode: "service:syslog-ng" depends_on: - syslog-ng + - broker # The dependency trees the build consumes. Set explicitly so they are present # regardless of which user's shell profile the image exports them from. environment: diff --git a/run-report.txt b/run-report.txt index 8459890..cc876d9 100644 --- a/run-report.txt +++ b/run-report.txt @@ -11,7 +11,7 @@ [report] flash_data,656,236,420 [report] static_bss,133152,164508,-31356 [report] heap_used,4440,39224,-34784 -[report] mbedtls_peak,22236,-,- +[report] mbedtls_peak,22208,-,- [report] stack_log,832,120,712 [report] stack_service,3852,56,3796 [report] --- end --- @@ -21,16 +21,18 @@ text data bss dec hex filename 361728 664 133152 495544 78fb8 /w/build/baseline-cross/baseline.elf ---- Oracle listeners (proved before the device ran) --- - OK udp 5514 - OK tcp 5601 - OK tls 6514 - OK mtls 6515 - OK mtls 6515 — refused a client with no certificate +--- Listeners (proved before the device ran) --- + OK udp 5514 + OK tcp 5601 + OK tls 6514 + OK mtls 6515 + OK mtls 6515 — refused a client with no certificate + OK broker 8883 + OK broker 8883 — refused a client with no certificate --- Collector (syslog-ng) received --- - wire <134>1 2026-07-28T18:22:03.100000Z 10.0.2.15 solid-syslog-example - BOOT [meta sequenceId="1" sysUpTime="210"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] device started - parsed PRIORITY=134 TIMESTAMP=2026-07-28T18:22:03+00:00 HOSTNAME=10.0.2.15 APP_NAME=solid-syslog-example PROCID= MSGID=BOOT STRUCTURED_DATA=[meta sequenceId="1" sysUpTime="210"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] MSG=device started + wire <134>1 2026-07-28T19:08:14.100000Z 10.0.2.15 solid-syslog-example - BOOT [meta sequenceId="1" sysUpTime="210"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] device started + parsed PRIORITY=134 TIMESTAMP=2026-07-28T19:08:14+00:00 HOSTNAME=10.0.2.15 APP_NAME=solid-syslog-example PROCID= MSGID=BOOT STRUCTURED_DATA=[meta sequenceId="1" sysUpTime="210"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] MSG=device started --- Baseline self-check (vs measurements/Secure.csv, tolerance 64 B) --- (no committed measurements/Secure.csv yet — rerun with CAPTURE=1 to freeze it) diff --git a/scripts/gen-certs.sh b/scripts/gen-certs.sh index f72ce2d..77c9a40 100644 --- a/scripts/gen-certs.sh +++ b/scripts/gen-certs.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# The test secrets for ./run.sh: one CA, a collector server certificate, a device -# client certificate for mTLS, and the device's provisioned symmetric keys. +# The test secrets for ./run.sh: one CA, a collector server certificate, a broker +# server certificate, a device client certificate for mTLS, and the device's +# provisioned symmetric keys. # # Regenerated on every run and never committed. Nothing here is a secret worth # keeping, and a fresh PKI each run is what stops the device quietly passing @@ -18,9 +19,9 @@ set -euo pipefail OUT="${1:-/w/build/certs}" DAYS=3650 -# The address the device reaches the collector on: QEMU's slirp gateway. It must -# appear as a SAN or the device's hostname verification rejects the collector. -COLLECTOR_IP="10.0.2.2" +# The address the device reaches everything on: QEMU's slirp gateway. It must +# appear as a SAN or the device's hostname verification rejects the peer. +GATEWAY_IP="10.0.2.2" mkdir -p "$OUT" cd "$OUT" @@ -39,7 +40,17 @@ openssl req -new -key collector.key \ -subj "/O=solid-syslog-example/CN=collector" -out collector.csr 2>/dev/null openssl x509 -req -in collector.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ -days "$DAYS" -sha256 -out collector.crt \ - -extfile <(printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n' "$COLLECTOR_IP") 2>/dev/null + -extfile <(printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n' "$GATEWAY_IP") 2>/dev/null + +# --- the broker (server) ----------------------------------------------------- +# The system the device already speaks mTLS to, before SolidSyslog exists. Its +# own certificate rather than the collector's, because it is a different peer. +newkey broker.key +openssl req -new -key broker.key \ + -subj "/O=solid-syslog-example/CN=broker" -out broker.csr 2>/dev/null +openssl x509 -req -in broker.csr -CA ca.crt -CAkey ca.key -CAcreateserial \ + -days "$DAYS" -sha256 -out broker.crt \ + -extfile <(printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n' "$GATEWAY_IP") 2>/dev/null # --- the device (client, for mTLS) ------------------------------------------- newkey device.key @@ -57,7 +68,7 @@ for name in device-storage log-store; do openssl rand -out "${name}.key" 32 done -rm -f collector.csr device.csr ca.srl +rm -f collector.csr broker.csr device.csr ca.srl chmod 644 ./*.crt ./*.key echo "PKI in ${OUT}: $(ls *.crt *.key | tr '\n' ' ')" diff --git a/scripts/run.sh b/scripts/run.sh index 0ebcd0c..ed92c18 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -39,7 +39,7 @@ echo "=== build (${TAG}) ===" cmake --build "$BUILD_DIR" -j"$(nproc)" [ -f "$ELF" ] || { echo "FAIL: $ELF not built" >&2; exit 1; } -echo "=== prove the oracle listeners ===" +echo "=== prove the listeners ===" set +e SMOKE_OUT="$(ORACLE_LOG_DIR="$ORACLE_LOG_DIR" bash "${REPO}/scripts/smoke-oracle.sh")" SMOKE_RC=$? @@ -137,7 +137,7 @@ fi verdict="PASS" exit_code=0 if [ "$SMOKE_RC" -ne 0 ]; then - verdict="FAIL (${SMOKE_RC} oracle listener(s) unproved)"; exit_code=1 + verdict="FAIL (${SMOKE_RC} listener(s) unproved)"; exit_code=1 elif [ "$RC" -ne 0 ]; then verdict="FAIL (qemu exit $RC)"; exit_code=1 elif [ "$delivered" != "1" ]; then @@ -155,12 +155,12 @@ report="$( echo "================ solid-syslog-example :: run (${TAG}) ================" echo echo "--- Device (self-measured; app talks to no collector at Baseline) ---" - grep -E '^\[device\]|^\[report\]|^\[syslog\]' <<<"$APP_OUT" || true + grep -E '^\[device\]|^\[sim\]|^\[report\]|^\[syslog\]' <<<"$APP_OUT" || true echo echo " size cross-check:" arm-none-eabi-size "$ELF" | sed 's/^/ /' echo - echo "--- Oracle listeners (proved before the device ran) ---" + echo "--- Listeners (proved before the device ran) ---" printf '%s\n' "$SMOKE_OUT" echo echo "--- Collector (syslog-ng) received ---" diff --git a/scripts/smoke-oracle.sh b/scripts/smoke-oracle.sh index aa6d48e..0613a97 100644 --- a/scripts/smoke-oracle.sh +++ b/scripts/smoke-oracle.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Prove every oracle listener, before any tag depends on one. +# Prove every listener the run depends on, before the device runs. # # A listener that syslog-ng parsed is not a listener that works: a wrong path in a # tls() block, a certificate without the right SAN, a peer-verify that quietly @@ -7,6 +7,11 @@ # tries to connect, several tags later. This sends one record over each transport # and checks it arrived, so the failure lands here instead. # +# The device's broker is proved for a different reason. The baseline holds an mTLS +# session to it for the whole run, and what that session costs is what SolidSyslog +# is then NOT charged for. A dead broker would read as "the baseline uses less +# memory" rather than as a failure, so it is proved here too. +# # Runs in the `run` container, which shares the oracle's network namespace, so the # listeners are on localhost. Records carry app-name "oracle-smoke", which # syslog-ng routes to smoke.log and away from the received_*.log the run reports. @@ -55,39 +60,69 @@ send_mtls_nocert() { -CAfile "${CERTS}/ca.crt" -quiet -no_ign_eof >/dev/null 2>&1 } +# The broker speaks no syslog, so its evidence is the reversed echo it sends back +# rather than a line in the collector's log — proof the session carried bytes, +# not merely that a handshake completed. +BROKER_PROBE=BROKER +BROKER_ECHO=REKORB + +broker_echo() { # extra s_client args, e.g. the client certificate + printf '%s\n' "$BROKER_PROBE" | timeout 10 openssl s_client -connect "${HOST}:8883" \ + -CAfile "${CERTS}/ca.crt" -verify_return_error -quiet "$@" 2>/dev/null +} + send_udp || true send_tcp || true send_tls || true send_mtls || true send_mtls_nocert || true +# depends_on guarantees the broker container was started, not that s_server has +# bound its socket, so the first probe doubles as the wait. Retrying the real +# check rather than polling the port separately keeps one mechanism: if it never +# answers, this fails exactly as it would have anyway. The certless probe follows +# it, so it runs against a broker already known to be up — which is the only way +# a refusal proves anything. +broker_authenticated="" +for _ in $(seq 1 10); do + broker_authenticated="$(broker_echo -cert "${CERTS}/device.crt" -key "${CERTS}/device.key")" + if grep -qxF "$BROKER_ECHO" <<<"$broker_authenticated"; then + break + fi + sleep 1 +done +broker_certless="$(broker_echo)" + # Let syslog-ng flush all four before reading back. sleep 2 received="$(cat "$SMOKE_LOG" 2>/dev/null || true)" failures=0 -check() { # $1 = label, $2 = port, $3 = expected line - if grep -qxF "$3" <<<"$received"; then - printf ' OK %-5s %s\n' "$1" "$2" +check() { # $1 = haystack, $2 = label, $3 = port, $4 = expected line + if grep -qxF "$4" <<<"$1"; then + printf ' OK %-6s %s\n' "$2" "$3" else - printf ' FAIL %-5s %s — no record arrived\n' "$1" "$2" + printf ' FAIL %-6s %s — nothing came back\n' "$2" "$3" failures=$((failures + 1)) fi } -refuse() { # $1 = label, $2 = port, $3 = line that must NOT be there - if grep -qxF "$3" <<<"$received"; then - printf ' FAIL %-5s %s — accepted a client with no certificate\n' "$1" "$2" +refuse() { # $1 = haystack, $2 = label, $3 = port, $4 = line that must NOT be there + if grep -qxF "$4" <<<"$1"; then + printf ' FAIL %-6s %s — accepted a client with no certificate\n' "$2" "$3" failures=$((failures + 1)) else - printf ' OK %-5s %s — refused a client with no certificate\n' "$1" "$2" + printf ' OK %-6s %s — refused a client with no certificate\n' "$2" "$3" fi } -check udp 5514 "UDP" -check tcp 5601 "TCP" -check tls 6514 "TLS" -check mtls 6515 "MTLS" -refuse mtls 6515 "MTLSNOCERT" +check "$received" udp 5514 "UDP" +check "$received" tcp 5601 "TCP" +check "$received" tls 6514 "TLS" +check "$received" mtls 6515 "MTLS" +refuse "$received" mtls 6515 "MTLSNOCERT" + +check "$broker_authenticated" broker 8883 "$BROKER_ECHO" +refuse "$broker_certless" broker 8883 "$BROKER_ECHO" exit "$failures" From a90d4332c859eb756af31ec0c5cce77cb96141e8 Mon Sep 17 00:00:00 2001 From: David Cozens Date: Tue, 28 Jul 2026 20:31:17 +0100 Subject: [PATCH 2/2] feat: hold a real mutual-TLS session to the broker from bring-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half. The device now opens a session to the broker at bring-up, over lwIP's raw TCP and mbedTLS, with the credentials DeviceCertStore already holds — and never closes it. Held rather than opened-and-closed on purpose: two sessions that do not overlap measure max(), not sum(), and it is the sum that says what a second session costs. flash_text 361,736 -> 362,968 (+1,232) static_bss 133,152 -> 157,280 (+24,128) mbedtls_peak 22,208 -> 37,224 (+15,016) heap_used 4,440 -> 4,440 The last line is the point of the exercise. mbedTLS's buffer used to peak at 22,208 bytes with SolidSyslog's session in it and nothing else; with the device's own session open alongside it, 37,224. So a second concurrent session costs about 15,000 bytes, not 22,208 — the difference is the parsed certificates, the DRBG and the rest of the first session's one-off state, which a device speaking mTLS has paid for already. Once the baseline is regenerated it will hold that first session, and what SolidSyslog is charged for TLS memory becomes the marginal figure, measured rather than argued. "About", because mbedtls_peak is not byte-reproducible. Five runs of this image gave 37,152 / 37,224 / 37,236 / 37,244 / 37,248 — a spread wider than the run's 64-byte drift tolerance, for the same reason heap_used moves: gen-certs.sh builds a fresh PKI every run and DER lengths shift with the key material. The figures above are the two committed run reports, so the arithmetic is checkable; the marginal cost it yields is good to about a hundred bytes, not to the byte. That has to be settled before the baseline is frozen with this key in it. flash_text and static_bss are exact. The static RAM growth is almost entirely the buffer: 32 KiB to 55 KiB, which is the peak times 1.5 rounded up to the next KiB. The margin is not padding — buffer_alloc hands out contiguous space, and 24 KiB against a 22.2 KiB peak failed on fragmentation where 32 KiB worked. The remaining 576 bytes are the ssl context, its config, and the TCP state. The keep-alive list loses the mbedTLS client surface and most of the TCP entries, because the session calls them for real now and naming a function there that the device runs would say something untrue. Flash did not move for it: those call closures were in the image either way, which is what the list was for. What stays is what is still unexercised — UDP, the file API, and the teardown path a session held for the life of the device never reaches. Co-Authored-By: Claude Opus 5 (1M context) --- CMakeLists.txt | 5 +- app/AppConfig.h | 11 +- app/main.c | 6 +- app/sim/SimulatedBrokerSession.c | 357 +++++++++++++++++++++++++++++++ app/sim/SimulatedBrokerSession.h | 38 ++++ app/sim/SimulatedExistingApp.c | 83 +++---- app/sim/SimulatedExistingApp.h | 13 +- run-report.txt | 15 +- 8 files changed, 456 insertions(+), 72 deletions(-) create mode 100644 app/sim/SimulatedBrokerSession.c create mode 100644 app/sim/SimulatedBrokerSession.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 56af4a3..6f7beb6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -141,6 +141,7 @@ endforeach() add_executable(baseline ${APP_DIR}/main.c ${APP_DIR}/sim/SimulatedExistingApp.c + ${APP_DIR}/sim/SimulatedBrokerSession.c ${APP_DIR}/tasks/LogTask.c ${APP_DIR}/tasks/ServiceTask.c ${APP_DIR}/measure/Measure.c @@ -187,8 +188,8 @@ target_include_directories(baseline PRIVATE ${FATFS_STAGE_DIR} ) -# sim/SimulatedExistingApp.c includes — it MUST see the same user -# config as the library, or context struct sizes diverge between consumer and library. +# app/sim includes — it MUST see the same user config as the +# library, or context struct sizes diverge between consumer and library. target_compile_definitions(baseline PRIVATE MBEDTLS_USER_CONFIG_FILE=${MBEDTLS_USER_CONFIG_HEADER}) # SolidSyslog is the Core library (a real .a); the SolidSyslog::* packs are diff --git a/app/AppConfig.h b/app/AppConfig.h index 5a5e8cb..aa57019 100644 --- a/app/AppConfig.h +++ b/app/AppConfig.h @@ -42,9 +42,12 @@ * the report prints absolutes for us to commit as the baseline. */ #define BASELINE_FILE_PATH "measurements/Baseline.csv" -/* The static buffer mbedTLS sub-allocates from. Sized from the high-water mark the - * device reports, so a step that asks more of mbedTLS grows this and is charged for - * it — the baseline holds no spare capacity on a later step's behalf. */ -#define SIMULATED_APP_MBEDTLS_HEAP_BYTES (32 * 1024) +/* The static buffer mbedTLS sub-allocates from. Sized at the high-water mark the + * device reports times 1.5, rounded up to the next KiB — buffer_alloc hands out + * contiguous space, so a buffer only a little over the peak fails on + * fragmentation rather than on capacity. One rule, applied again at every step + * that asks more of mbedTLS, so the baseline holds no spare capacity on a later + * step's behalf and each step is charged what it actually added. */ +#define SIMULATED_APP_MBEDTLS_HEAP_BYTES (55 * 1024) #endif /* APP_CONFIG_H */ diff --git a/app/main.c b/app/main.c index 7c0f389..5b2a787 100644 --- a/app/main.c +++ b/app/main.c @@ -2,8 +2,8 @@ * with ZERO SolidSyslog. * * This is the "simulated existing application": a device that already networks, - * already uses storage, and has TLS on board — the frozen platform SolidSyslog's - * footprint deltas are measured against. main() is deliberately thin: it starts + * already uses storage, and already holds a TLS session — the frozen platform + * SolidSyslog's footprint deltas are measured against. main() is deliberately thin: it starts * the console, creates the two idle application seams (a log source and a service * worker), and hands off to a harness task that brings up the simulated existing * application, measures the cost above the frozen baseline, and exits. @@ -68,7 +68,7 @@ static void HarnessTask(void* parameters) (void) printf("[device] starting simulated existing application...\n"); bool simReady = SimulatedExistingApp_Start(); (void) printf( - "[device] sim app (lwIP up, FatFs mounted, mTLS credentials loaded, mbedTLS linked): %s\n", + "[device] sim app (lwIP up, FatFs mounted, broker session held over mTLS): %s\n", simReady ? "ready" : "FAILED" ); diff --git a/app/sim/SimulatedBrokerSession.c b/app/sim/SimulatedBrokerSession.c new file mode 100644 index 0000000..a1f3554 --- /dev/null +++ b/app/sim/SimulatedBrokerSession.c @@ -0,0 +1,357 @@ +/* See SimulatedBrokerSession.h. */ + +#include "SimulatedBrokerSession.h" + +#include "DeviceCertStore.h" + +#include "lwip/ip_addr.h" +#include "lwip/pbuf.h" +#include "lwip/tcp.h" +#include "lwip/tcpip.h" + +#include "mbedtls/ctr_drbg.h" +#include "mbedtls/ssl.h" + +#include "FreeRTOS.h" +#include "task.h" + +#include +#include + +/* The broker, on the IANA secure-MQTT port, reached through QEMU's slirp + * gateway. It has to match a SAN in the broker certificate or the hostname + * check below rejects it. */ +#define BROKER_HOST "10.0.2.2" +#define BROKER_PORT ((uint16_t) 8883U) + +/* Bounded throughout, so a broker that never answers fails the bring-up rather + * than hanging it. One poll is one tick at this device's tick rate. */ +#define BROKER_POLL_MS 10U +#define BROKER_CONNECT_TIMEOUT_MS 5000U +#define BROKER_HANDSHAKE_TIMEOUT_MS 15000U +#define BROKER_REPLY_TIMEOUT_MS 5000U + +/* One exchange, because a completed handshake does not show that the session + * carries traffic. The far end echoes whatever it is sent. */ +static const char BROKER_HELLO[] = "device-online\n"; + +/* The TCP half. lwIP's raw API is callback-driven and belongs to one thread, so + * every call into it below is made under the core lock, and the callbacks touch + * nothing else. */ +static struct tcp_pcb* s_pcb; +static struct pbuf* s_receiveQueue; +static uint16_t s_receiveOffset; +static volatile bool s_connected; +static volatile bool s_errored; + +static mbedtls_ssl_context s_ssl; +static mbedtls_ssl_config s_sslConfig; + +static bool BrokerSession_Failed(const char* what, int code) +{ + (void) printf("[sim] broker: %s failed (-0x%04x)\n", what, (unsigned) -code); + return false; +} + +static err_t BrokerSession_OnConnected(void* argument, struct tcp_pcb* pcb, err_t error) +{ + (void) argument; + (void) pcb; + + if (error == ERR_OK) + { + s_connected = true; + } + else + { + s_errored = true; + } + return ERR_OK; +} + +/* A NULL pbuf is the peer's FIN. Anything else joins the tail of what is already + * queued; the read cursor is tracked separately, so appending to a partly-read + * chain is safe. */ +static err_t BrokerSession_OnReceive(void* argument, struct tcp_pcb* pcb, struct pbuf* received, err_t error) +{ + (void) argument; + (void) pcb; + (void) error; + + if (received == NULL) + { + s_errored = true; + } + else if (s_receiveQueue == NULL) + { + s_receiveQueue = received; + } + else + { + pbuf_cat(s_receiveQueue, received); + } + return ERR_OK; +} + +/* lwIP has already released the pcb by the time this runs, so the pointer must + * be dropped rather than closed. */ +static void BrokerSession_OnError(void* argument, err_t error) +{ + (void) argument; + (void) error; + + s_pcb = NULL; + s_errored = true; +} + +/* Waits on the calling task, never on the tcpip thread — sleeping there would + * starve the RX and timer paths the connect needs to make progress. */ +static bool BrokerSession_WaitConnected(void) +{ + uint32_t elapsedMs = 0; + while (!s_connected && !s_errored && (elapsedMs < BROKER_CONNECT_TIMEOUT_MS)) + { + vTaskDelay(pdMS_TO_TICKS(BROKER_POLL_MS)); + elapsedMs += BROKER_POLL_MS; + } + return s_connected; +} + +static void BrokerSession_AbortTcp(void) +{ + LOCK_TCPIP_CORE(); + if (s_pcb != NULL) + { + tcp_abort(s_pcb); + s_pcb = NULL; + } + UNLOCK_TCPIP_CORE(); +} + +static bool BrokerSession_OpenTcp(void) +{ + ip_addr_t address; + if (!ipaddr_aton(BROKER_HOST, &address)) + { + (void) printf("[sim] broker: %s is not an address\n", BROKER_HOST); + return false; + } + + err_t connectError = ERR_MEM; + LOCK_TCPIP_CORE(); + s_pcb = tcp_new(); + if (s_pcb != NULL) + { + /* With Nagle on, a sub-MSS handshake flight is held until the previous + * segment is acked — and the peer only acks once it has the whole + * flight. The two wait for each other. */ + tcp_nagle_disable(s_pcb); + tcp_recv(s_pcb, BrokerSession_OnReceive); + tcp_err(s_pcb, BrokerSession_OnError); + connectError = tcp_connect(s_pcb, &address, BROKER_PORT, BrokerSession_OnConnected); + } + UNLOCK_TCPIP_CORE(); + + bool ok = (connectError == ERR_OK) && BrokerSession_WaitConnected(); + if (!ok) + { + BrokerSession_AbortTcp(); + (void) printf("[sim] broker: no TCP connection to %s:%u\n", BROKER_HOST, (unsigned) BROKER_PORT); + } + return ok; +} + +static int BrokerSession_Send(void* context, const unsigned char* buffer, size_t length) +{ + (void) context; + + int result = -1; + LOCK_TCPIP_CORE(); + if (s_pcb != NULL) + { + err_t writeError = tcp_write(s_pcb, buffer, (u16_t) length, TCP_WRITE_FLAG_COPY); + if (writeError == ERR_OK) + { + /* ERR_MEM from tcp_output means lwIP has the bytes and will retry. */ + err_t outputError = tcp_output(s_pcb); + result = ((outputError == ERR_OK) || (outputError == ERR_MEM)) ? (int) length : -1; + } + else if (writeError == ERR_MEM) + { + /* The send buffer is full. Retryable, not fatal — tearing the session + * down over a full window would be a self-inflicted disconnect. */ + result = MBEDTLS_ERR_SSL_WANT_WRITE; + } + else + { + /* Keep result = -1: a real write failure. */ + } + } + UNLOCK_TCPIP_CORE(); + return result; +} + +/* mbedTLS reads through a non-blocking transport, so "nothing yet" has to be + * WANT_READ rather than 0 — a 0 would end the handshake on the first poll. */ +static int BrokerSession_Receive(void* context, unsigned char* buffer, size_t length) +{ + (void) context; + + int result; + LOCK_TCPIP_CORE(); + if (s_receiveQueue != NULL) + { + u16_t available = (u16_t) (s_receiveQueue->tot_len - s_receiveOffset); + u16_t wanted = (length < (size_t) available) ? (u16_t) length : available; + u16_t copied = pbuf_copy_partial(s_receiveQueue, buffer, wanted, s_receiveOffset); + + s_receiveOffset = (uint16_t) (s_receiveOffset + copied); + if (s_receiveOffset >= s_receiveQueue->tot_len) + { + (void) pbuf_free(s_receiveQueue); + s_receiveQueue = NULL; + s_receiveOffset = 0; + } + if (s_pcb != NULL) + { + /* Reopen the window by what was consumed, not by what arrived. */ + tcp_recved(s_pcb, copied); + } + result = (int) copied; + } + else + { + result = s_errored ? -1 : MBEDTLS_ERR_SSL_WANT_READ; + } + UNLOCK_TCPIP_CORE(); + return result; +} + +static bool BrokerSession_Configure(void) +{ + mbedtls_ssl_init(&s_ssl); + mbedtls_ssl_config_init(&s_sslConfig); + + int result = mbedtls_ssl_config_defaults( + &s_sslConfig, + MBEDTLS_SSL_IS_CLIENT, + MBEDTLS_SSL_TRANSPORT_STREAM, + MBEDTLS_SSL_PRESET_DEFAULT + ); + if (result != 0) + { + return BrokerSession_Failed("ssl defaults", result); + } + + /* The device authenticates the broker, and presents its own certificate so + * the broker can authenticate the device. Either half missing is not mTLS. */ + mbedtls_ssl_conf_authmode(&s_sslConfig, MBEDTLS_SSL_VERIFY_REQUIRED); + mbedtls_ssl_conf_ca_chain(&s_sslConfig, DeviceCertStore_CaChain(), NULL); + mbedtls_ssl_conf_rng(&s_sslConfig, mbedtls_ctr_drbg_random, DeviceCertStore_Rng()); + + result = mbedtls_ssl_conf_own_cert(&s_sslConfig, DeviceCertStore_ClientChain(), DeviceCertStore_ClientKey()); + if (result != 0) + { + return BrokerSession_Failed("client certificate", result); + } + + result = mbedtls_ssl_setup(&s_ssl, &s_sslConfig); + if (result != 0) + { + return BrokerSession_Failed("ssl setup", result); + } + + result = mbedtls_ssl_set_hostname(&s_ssl, BROKER_HOST); + if (result != 0) + { + return BrokerSession_Failed("expected hostname", result); + } + + mbedtls_ssl_set_bio(&s_ssl, NULL, BrokerSession_Send, BrokerSession_Receive, NULL); + return true; +} + +static bool BrokerSession_IsRetryable(int result) +{ + return (result == MBEDTLS_ERR_SSL_WANT_READ) || (result == MBEDTLS_ERR_SSL_WANT_WRITE); +} + +static bool BrokerSession_Handshake(void) +{ + uint32_t elapsedMs = 0; + int result = mbedtls_ssl_handshake(&s_ssl); + while (BrokerSession_IsRetryable(result) && (elapsedMs < BROKER_HANDSHAKE_TIMEOUT_MS)) + { + vTaskDelay(pdMS_TO_TICKS(BROKER_POLL_MS)); + elapsedMs += BROKER_POLL_MS; + result = mbedtls_ssl_handshake(&s_ssl); + } + if (result != 0) + { + return BrokerSession_Failed("handshake", result); + } + + /* VERIFY_REQUIRED has already failed the handshake on a bad chain; asking + * again is what turns "it connected" into a reason it was allowed to. */ + uint32_t verdict = mbedtls_ssl_get_verify_result(&s_ssl); + if (verdict != 0) + { + (void) printf("[sim] broker: peer certificate rejected (0x%08lx)\n", (unsigned long) verdict); + return false; + } + return true; +} + +static bool BrokerSession_Exchange(void) +{ + uint32_t elapsedMs = 0; + int result = mbedtls_ssl_write(&s_ssl, (const unsigned char*) BROKER_HELLO, sizeof(BROKER_HELLO) - 1U); + while (BrokerSession_IsRetryable(result) && (elapsedMs < BROKER_REPLY_TIMEOUT_MS)) + { + vTaskDelay(pdMS_TO_TICKS(BROKER_POLL_MS)); + elapsedMs += BROKER_POLL_MS; + result = mbedtls_ssl_write(&s_ssl, (const unsigned char*) BROKER_HELLO, sizeof(BROKER_HELLO) - 1U); + } + if (result <= 0) + { + return BrokerSession_Failed("send", result); + } + + /* What comes back is not inspected — what the broker answers is proved in + * scripts/smoke-oracle.sh, before the device runs. Here it only has to + * arrive, over the session, from the peer that authenticated. */ + unsigned char reply[32]; + elapsedMs = 0; + result = mbedtls_ssl_read(&s_ssl, reply, sizeof(reply)); + while (BrokerSession_IsRetryable(result) && (elapsedMs < BROKER_REPLY_TIMEOUT_MS)) + { + vTaskDelay(pdMS_TO_TICKS(BROKER_POLL_MS)); + elapsedMs += BROKER_POLL_MS; + result = mbedtls_ssl_read(&s_ssl, reply, sizeof(reply)); + } + if (result <= 0) + { + return BrokerSession_Failed("reply", result); + } + return true; +} + +bool SimulatedBrokerSession_Open(void) +{ + /* Nothing closes it. The session is held for the life of the device, which + * is what makes it concurrent with SolidSyslog's rather than sequential — + * two sessions that never overlap would measure the larger, not the sum. */ + bool ok = BrokerSession_OpenTcp() && BrokerSession_Configure() && BrokerSession_Handshake() + && BrokerSession_Exchange(); + if (ok) + { + (void) printf( + "[sim] broker session to %s:%u: %s, %s\n", + BROKER_HOST, + (unsigned) BROKER_PORT, + mbedtls_ssl_get_version(&s_ssl), + mbedtls_ssl_get_ciphersuite(&s_ssl) + ); + } + return ok; +} diff --git a/app/sim/SimulatedBrokerSession.h b/app/sim/SimulatedBrokerSession.h new file mode 100644 index 0000000..7a9c119 --- /dev/null +++ b/app/sim/SimulatedBrokerSession.h @@ -0,0 +1,38 @@ +/* The mutual-TLS session this device already holds to its cloud broker. + * + * A secured device does not link a TLS stack and leave it idle — it keeps a + * session open to something. This is that session: opened at bring-up and held + * for the life of the device, over lwIP's raw TCP API and mbedTLS, using the + * same provisioned credentials DeviceCertStore hands out. + * + * It exists so the baseline's mbedTLS cost is a session's cost. What SolidSyslog + * then adds is what a *second* concurrent session adds, which is measured rather + * than asserted. Whether a given product's two connections truly overlap is the + * product's business; this one assumes they do, which is the conservative + * reading for the baseline and the honest one for us. + * + * On a real device the peer is an MQTT broker, a device-management service or a + * cloud gateway, and the session carries application traffic. Here the far end + * is an openssl s_server (see docker/docker-compose.yml) and the traffic is one + * exchange, because what is being measured is the session, not the protocol. */ +#ifndef APP_SIM_SIMULATED_BROKER_SESSION_H +#define APP_SIM_SIMULATED_BROKER_SESSION_H + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + /** Connect, authenticate both ways, and leave the session open. False if the + * broker is unreachable, the handshake fails, or the peer does not verify — + * on a device that is a failed bring-up, and here it fails the run, because + * a session that did not open is memory the baseline did not spend. */ + bool SimulatedBrokerSession_Open(void); + +#ifdef __cplusplus +} +#endif + +#endif /* APP_SIM_SIMULATED_BROKER_SESSION_H */ diff --git a/app/sim/SimulatedExistingApp.c b/app/sim/SimulatedExistingApp.c index 830f180..83567ec 100644 --- a/app/sim/SimulatedExistingApp.c +++ b/app/sim/SimulatedExistingApp.c @@ -1,18 +1,19 @@ /* The simulated existing application — see SimulatedExistingApp.h. * - * This is the "device you already have": networked, with storage, with a TLS - * library on board. lwIP and FatFs are brought up (their runtime cost is - * baseline); mbedTLS is linked but holds no session (SolidSyslog opens the - * secure channel from Secure). The lwIP raw UDP/TCP API, the FatFs file API, and the - * mbedTLS client surface that SolidSyslog will call are all rooted into the - * image here — linked, never run — so their flash is counted below the line and - * a later tag's delta is only the SolidSyslog code that drives them. */ + * This is the "device you already have": networked, with storage, and holding a + * mutual-TLS session to its broker. lwIP, FatFs and that session are all brought + * up here, so their runtime cost is baseline. What is left of the platform + * surface SolidSyslog will call — datagrams, files, the teardown path a + * held-open session never runs — is rooted into the image below without being + * run, so its flash is counted below the line too and a later tag's delta is + * only the SolidSyslog code that drives it. */ #include "SimulatedExistingApp.h" #include "AppConfig.h" #include "DeviceCertStore.h" #include "EthernetIf.h" +#include "SimulatedBrokerSession.h" #include "lwip/ip4_addr.h" #include "lwip/netif.h" @@ -22,12 +23,8 @@ #include "ff.h" -#include "mbedtls/ctr_drbg.h" #include "mbedtls/memory_buffer_alloc.h" -#include "mbedtls/pk.h" -#include "mbedtls/platform.h" #include "mbedtls/ssl.h" -#include "mbedtls/x509_crt.h" #include "psa/crypto.h" #include "FreeRTOS.h" @@ -37,11 +34,9 @@ #include #include #include -#include -/* Everything mbedTLS allocates comes out of this, and nothing else uses it. Sized - * from the high-water mark the device reports, so it grows when a step asks more - * of mbedTLS rather than carrying spare capacity in advance. */ +/* Everything mbedTLS allocates comes out of this, and nothing else uses it. + * Sized in AppConfig.h, from what this measures. */ static uint8_t s_mbedtlsHeap[SIMULATED_APP_MBEDTLS_HEAP_BYTES]; size_t SimulatedExistingApp_MbedTlsPeak(void) @@ -89,28 +84,28 @@ psa_status_t mbedtls_psa_external_get_random( * everything it calls — so referencing these top-level entry points drags their * whole static call closures into the image under --gc-sections. The addresses * are XOR'd into a volatile sink reached from main(), which keeps this reachable - * (belt-and-braces over the `used` attribute). Nothing is called. */ + * (belt-and-braces over the `used` attribute). Nothing here is called. + * + * Only what the device does not itself run belongs in this list. The broker + * session calls the mbedTLS client surface and lwIP's TCP path for real, so + * those root themselves and naming them here would say something untrue. */ static volatile uintptr_t s_keepAliveSink; __attribute__((used)) static void KeepPlatformLinked(void) { static const uintptr_t surface[] = { - /* lwIP raw UDP + TCP — the SolidSyslog UdpSender (Minimal) / StreamSender (Secure). */ + /* lwIP raw UDP — the SolidSyslog UdpSender (Minimal). The device's own + * traffic is TCP, so nothing else reaches these. */ (uintptr_t) &udp_new, (uintptr_t) &udp_bind, (uintptr_t) &udp_connect, (uintptr_t) &udp_sendto, (uintptr_t) &udp_recv, (uintptr_t) &udp_remove, - (uintptr_t) &tcp_new, + /* lwIP raw TCP, the parts a session that is never closed does not use. */ (uintptr_t) &tcp_bind, - (uintptr_t) &tcp_connect, - (uintptr_t) &tcp_write, - (uintptr_t) &tcp_output, - (uintptr_t) &tcp_recv, (uintptr_t) &tcp_sent, (uintptr_t) &tcp_close, - (uintptr_t) &tcp_abort, /* FatFs file API — the SolidSyslog BlockStore (Secure). */ (uintptr_t) &f_open, (uintptr_t) &f_close, @@ -121,34 +116,13 @@ __attribute__((used)) static void KeepPlatformLinked(void) (uintptr_t) &f_truncate, (uintptr_t) &f_unlink, (uintptr_t) &f_stat, - /* mbedTLS client + x509 + pk + DRBG + PSA — the SolidSyslog TLS transport - * (Secure/Hardened). Lifecycle and client-auth entry points are here too: - * a device already running mTLS calls those, not only the transfer ones. - * No CRL and no session resumption — this device checks neither. */ - (uintptr_t) &mbedtls_ssl_init, + /* The mbedTLS teardown path. A device that reconnects to its broker runs + * it; this one holds one session for its whole life and never does, so + * without this the cost would land on whoever closes first. */ + (uintptr_t) &mbedtls_ssl_close_notify, + (uintptr_t) &mbedtls_ssl_session_reset, (uintptr_t) &mbedtls_ssl_free, - (uintptr_t) &mbedtls_ssl_config_init, (uintptr_t) &mbedtls_ssl_config_free, - (uintptr_t) &mbedtls_ssl_setup, - (uintptr_t) &mbedtls_ssl_session_reset, - (uintptr_t) &mbedtls_ssl_handshake, - (uintptr_t) &mbedtls_ssl_read, - (uintptr_t) &mbedtls_ssl_write, - (uintptr_t) &mbedtls_ssl_close_notify, - (uintptr_t) &mbedtls_ssl_config_defaults, - (uintptr_t) &mbedtls_ssl_conf_authmode, - (uintptr_t) &mbedtls_ssl_conf_ca_chain, - (uintptr_t) &mbedtls_ssl_conf_own_cert, - (uintptr_t) &mbedtls_ssl_conf_rng, - (uintptr_t) &mbedtls_ssl_set_bio, - (uintptr_t) &mbedtls_ssl_set_hostname, - (uintptr_t) &mbedtls_ssl_get_verify_result, - (uintptr_t) &mbedtls_x509_crt_parse, - (uintptr_t) &mbedtls_pk_parse_key, - (uintptr_t) &mbedtls_ctr_drbg_init, - (uintptr_t) &mbedtls_ctr_drbg_seed, - (uintptr_t) &mbedtls_ctr_drbg_random, - (uintptr_t) &psa_crypto_init, /* The provisioned symmetric key lookup. Nothing in the simulated device * reads a key, so without this the accessor is stripped and the cost * reappears on whoever first asks for one. */ @@ -260,7 +234,7 @@ bool SimulatedExistingApp_StartCrypto(void) bool SimulatedExistingApp_Start(void) { - /* mbedTLS + the lwIP raw / FatFs file APIs: linked, never run. */ + /* The rest of the platform surface: linked, never run. */ KeepPlatformLinked(); /* lwIP: bring the netif up on the tcpip thread and wait for it to complete. */ @@ -271,5 +245,12 @@ bool SimulatedExistingApp_Start(void) } /* FatFs: mount (format a fresh image on first use). */ - return SimulatedExistingApp_MountFatFs(); + if (!SimulatedExistingApp_MountFatFs()) + { + return false; + } + + /* The broker session, once there is a network to open it on. It stays open + * from here to the end of the run. */ + return SimulatedBrokerSession_Open(); } diff --git a/app/sim/SimulatedExistingApp.h b/app/sim/SimulatedExistingApp.h index f572eec..6913b76 100644 --- a/app/sim/SimulatedExistingApp.h +++ b/app/sim/SimulatedExistingApp.h @@ -15,12 +15,15 @@ extern "C" * the baseline's, never a SolidSyslog delta; * - mounts FatFs (formatting a fresh image on first use) so the filesystem * is likewise baseline; - * - links — but never runs — the mbedTLS client surface plus the lwIP raw - * UDP/TCP and FatFs file APIs SolidSyslog will call from Minimal onward, so their - * flash is counted below the line. + * - opens the mutual-TLS session to the device's broker and holds it, so + * what a TLS session costs is likewise baseline (see + * SimulatedBrokerSession.h); + * - links — but never runs — the rest of the platform surface SolidSyslog + * will call from Minimal onward, so its flash is counted below the line. * - * Nothing here emits, sends, stores, or handshakes. Runs on a task (netif - * init and mount both block). Returns true once lwIP is up and FatFs mounted. + * Nothing here logs, or sends anything a collector will see. Runs on a task + * (netif init, mount and handshake all block). Returns true once all three + * are up. * * Must be called after tcpip_init() and after the scheduler has started. */ bool SimulatedExistingApp_Start(void); diff --git a/run-report.txt b/run-report.txt index cc876d9..d1664f8 100644 --- a/run-report.txt +++ b/run-report.txt @@ -3,15 +3,16 @@ --- Device (self-measured; app talks to no collector at Baseline) --- [device] solid-syslog-example (FreeRTOS + lwIP + mbedTLS + FatFs) [device] starting simulated existing application... -[device] sim app (lwIP up, FatFs mounted, mTLS credentials loaded, mbedTLS linked): ready +[sim] broker session to 10.0.2.2:8883: TLSv1.3, TLS1-3-CHACHA20-POLY1305-SHA256 +[device] sim app (lwIP up, FatFs mounted, broker session held over mTLS): ready [device] first record logged: yes [report] --- SolidSyslog cost above baseline (simulated existing application) --- [report] key,current,baseline,used_above_baseline -[report] flash_text,361736,345832,15904 +[report] flash_text,362968,345832,17136 [report] flash_data,656,236,420 -[report] static_bss,133152,164508,-31356 +[report] static_bss,157280,164508,-7228 [report] heap_used,4440,39224,-34784 -[report] mbedtls_peak,22208,-,- +[report] mbedtls_peak,37224,-,- [report] stack_log,832,120,712 [report] stack_service,3852,56,3796 [report] --- end --- @@ -19,7 +20,7 @@ size cross-check: text data bss dec hex filename - 361728 664 133152 495544 78fb8 /w/build/baseline-cross/baseline.elf + 362960 664 157280 520904 7f2c8 /w/build/baseline-cross/baseline.elf --- Listeners (proved before the device ran) --- OK udp 5514 @@ -31,8 +32,8 @@ OK broker 8883 — refused a client with no certificate --- Collector (syslog-ng) received --- - wire <134>1 2026-07-28T19:08:14.100000Z 10.0.2.15 solid-syslog-example - BOOT [meta sequenceId="1" sysUpTime="210"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] device started - parsed PRIORITY=134 TIMESTAMP=2026-07-28T19:08:14+00:00 HOSTNAME=10.0.2.15 APP_NAME=solid-syslog-example PROCID= MSGID=BOOT STRUCTURED_DATA=[meta sequenceId="1" sysUpTime="210"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] MSG=device started + wire <134>1 2026-07-28T19:29:47.320000Z 10.0.2.15 solid-syslog-example - BOOT [meta sequenceId="1" sysUpTime="232"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] device started + parsed PRIORITY=134 TIMESTAMP=2026-07-28T19:29:47+00:00 HOSTNAME=10.0.2.15 APP_NAME=solid-syslog-example PROCID= MSGID=BOOT STRUCTURED_DATA=[meta sequenceId="1" sysUpTime="232"][timeQuality tzKnown="1" isSynced="0"][origin software="solid-syslog-example" swVersion="0.1.0" enterpriseId="32473" ip="10.0.2.15"][logPipeline@32473 transport="mtls" atRest="aes-256-gcm"] MSG=device started --- Baseline self-check (vs measurements/Secure.csv, tolerance 64 B) --- (no committed measurements/Secure.csv yet — rerun with CAPTURE=1 to freeze it)