From 9b3df70ae0e6834e862be3e243d745bac354b8a0 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Tue, 16 Jun 2026 11:29:16 +0200 Subject: [PATCH 1/9] varlink: also support symlinks in varlink_execute_directory() --- src/libsystemd/sd-varlink/varlink-util.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libsystemd/sd-varlink/varlink-util.c b/src/libsystemd/sd-varlink/varlink-util.c index 7b8797c92c..1eace7724c 100644 --- a/src/libsystemd/sd-varlink/varlink-util.c +++ b/src/libsystemd/sd-varlink/varlink-util.c @@ -282,7 +282,7 @@ ssize_t varlink_execute_directory( FOREACH_ARRAY(dp, dentries->entries, dentries->n_entries) { struct dirent *de = *dp; - if (de->d_type != DT_SOCK) + if (!IN_SET(de->d_type, DT_SOCK, DT_LNK)) continue; t++; From d086eb6d2e3fce7e03cb65ec3593700db5367ffc Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 22:43:23 +0200 Subject: [PATCH 2/9] report: don't consider it an error if a backend replies io.systemd.Metrics.NoSuchMetric If an error returns this it (currently) has no metric to report. Which is entirely fine, treat it as such. --- src/report/report.c | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/report/report.c b/src/report/report.c index a4d7a9ea56..9c951f9832 100644 --- a/src/report/report.c +++ b/src/report/report.c @@ -232,6 +232,8 @@ static int on_query_reply( log_warning("Varlink connection to '%s' disconnected prematurely, ignoring.", li->name); else if (streq(error_id, SD_VARLINK_ERROR_TIMEOUT)) log_warning("Varlink connection to '%s' timed out, ignoring.", li->name); + else if (streq(error_id, "io.systemd.Metrics.NoSuchMetric")) + log_debug("Varlink connection to '%s' reported no more metrics, ignoring.", li->name); else log_warning("Varlink error from '%s', ignoring: %s", li->name, error_id); From 0d9026c736141db7c1bb99d573fc67f99cb48b1d Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Sun, 14 Jun 2026 07:48:36 +0200 Subject: [PATCH 3/9] report: separate report generation from upload Let's split report generation from uploading, as prepatory step for adding signing for reports. No real code changes, just moving things around. --- src/report/meson.build | 1 + src/report/report-generate.c | 51 ++++++++++++++++++++++++++++++++++ src/report/report-generate.h | 8 ++++++ src/report/report-upload.c | 54 ++++++++---------------------------- src/report/report-upload.h | 6 ++++ src/report/report.c | 22 +++++++++++++-- src/report/report.h | 4 --- 7 files changed, 96 insertions(+), 50 deletions(-) create mode 100644 src/report/report-generate.c create mode 100644 src/report/report-generate.h create mode 100644 src/report/report-upload.h diff --git a/src/report/meson.build b/src/report/meson.build index 5f05382999..e64f74ee4e 100644 --- a/src/report/meson.build +++ b/src/report/meson.build @@ -6,6 +6,7 @@ executables += [ 'public' : true, 'sources' : files( 'report.c', + 'report-generate.c', 'report-upload.c', ), }, diff --git a/src/report/report-generate.c b/src/report/report-generate.c new file mode 100644 index 0000000000..edac44a0ea --- /dev/null +++ b/src/report/report-generate.c @@ -0,0 +1,51 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-json.h" + +#include "log.h" +#include "report.h" +#include "report-generate.h" +#include "time-util.h" + +int context_build_report(Context *context, sd_json_variant **ret) { + int r; + + /* Convert the variant array to a JSON report. */ + + assert(context); + assert(ret); + + usec_t ts = now(CLOCK_REALTIME); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; + r = sd_json_buildo(&report, + SD_JSON_BUILD_PAIR_STRING("mediaType", "application/vnd.io.systemd.report"), + SD_JSON_BUILD_PAIR("timestamp", + SD_JSON_BUILD_STRING(FORMAT_TIMESTAMP_STYLE(ts, TIMESTAMP_UTC))), + SD_JSON_BUILD_PAIR("metrics", + SD_JSON_BUILD_VARIANT_ARRAY(context->metrics, context->n_metrics))); + if (r < 0) + return log_error_errno(r, "Failed to build JSON data: %m"); + + *ret = TAKE_PTR(report); + return 0; +} + +int context_generate_report(Context *context) { + int r; + + assert(context); + + /* Make a structured report and either print it or upload it. */ + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; + r = context_build_report(context, &report); + if (r < 0) + return r; + + r = sd_json_variant_dump(report, arg_json_format_flags, /* f= */ NULL, /* prefix= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to dump json object: %m"); + + return 0; +} diff --git a/src/report/report-generate.h b/src/report/report-generate.h new file mode 100644 index 0000000000..acde3db14c --- /dev/null +++ b/src/report/report-generate.h @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "report.h" + +int context_build_report(Context *context, sd_json_variant **ret); + +int context_generate_report(Context *context); diff --git a/src/report/report-upload.c b/src/report/report-upload.c index 02f15cba61..17ef327db0 100644 --- a/src/report/report-upload.c +++ b/src/report/report-upload.c @@ -6,6 +6,8 @@ #include "errno-util.h" #include "log.h" #include "report.h" +#include "report-generate.h" +#include "report-upload.h" #include "string-util.h" #include "strv.h" #include "time-util.h" @@ -13,11 +15,12 @@ #include "varlink-util.h" #include "version.h" +#define REPORT_UPLOAD_DIR "/run/systemd/report.upload" +#define SERVER_ANSWER_MAX (1U * 1024U * 1024U) + #if HAVE_LIBCURL #include "curl-util.h" -#define SERVER_ANSWER_MAX (1*1024*1024u) - static size_t output_callback(char *buf, size_t size, size_t nmemb, @@ -54,26 +57,6 @@ static size_t output_callback(char *buf, } #endif -static int build_json_report(Context *context, sd_json_variant **ret) { - /* Convert the variant array to a JSON report. */ - - assert(context); - assert(ret); - - usec_t ts = now(CLOCK_REALTIME); - int r; - - r = sd_json_buildo(ret, - SD_JSON_BUILD_PAIR_STRING("mediaType", "application/vnd.io.systemd.report"), - SD_JSON_BUILD_PAIR("timestamp", - SD_JSON_BUILD_STRING(FORMAT_TIMESTAMP_STYLE(ts, TIMESTAMP_UTC))), - SD_JSON_BUILD_PAIR("metrics", - SD_JSON_BUILD_VARIANT_ARRAY(context->metrics, context->n_metrics))); - if (r < 0) - return log_error_errno(r, "Failed to build JSON data: %m"); - return 0; -} - static int http_upload_collected(Context *context, sd_json_variant *report) { #if HAVE_LIBCURL _cleanup_(curl_slist_free_allp) struct curl_slist *header = NULL; @@ -235,9 +218,14 @@ static int execute_dir_reply( return 0; } -static int upload_collected(Context *context, sd_json_variant *report) { +int context_upload_report(Context *context) { int r; + _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; + r = context_build_report(context, &report); + if (r < 0) + return r; + if (arg_url) return http_upload_collected(context, report); @@ -267,23 +255,3 @@ static int upload_collected(Context *context, sd_json_variant *report) { log_debug("Upload via %s finished successfully.", REPORT_UPLOAD_DIR); return 0; } - -/* Make a structured report and either print it or upload it. */ -int report_collected(Context *context) { - _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; - int r; - - r = build_json_report(context, &report); - if (r < 0) - return r; - - if (context->action == ACTION_UPLOAD) - return upload_collected(context, report); - - /* Just print the report for now. */ - assert(context->action == ACTION_GENERATE); - r = sd_json_variant_dump(report, arg_json_format_flags, /* f= */ NULL, /* prefix= */ NULL); - if (r < 0) - return log_error_errno(r, "Failed to dump json object: %m"); - return 0; -} diff --git a/src/report/report-upload.h b/src/report/report-upload.h new file mode 100644 index 0000000000..eb4cb87f23 --- /dev/null +++ b/src/report/report-upload.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "report.h" + +int context_upload_report(Context *context); diff --git a/src/report/report.c b/src/report/report.c index 9c951f9832..c3af61f01d 100644 --- a/src/report/report.c +++ b/src/report/report.c @@ -16,6 +16,8 @@ #include "path-lookup.h" #include "recurse-dir.h" #include "report.h" +#include "report-generate.h" +#include "report-upload.h" #include "runtime-scope.h" #include "set.h" #include "sort-util.h" @@ -656,10 +658,24 @@ static int verb_metrics(int argc, char *argv[], uintptr_t data, void *userdata) if (r < 0) return log_error_errno(r, "Failed to run event loop: %m"); - if (IN_SET(action, ACTION_GENERATE, ACTION_UPLOAD)) - r = report_collected(&context); - else + switch (action) { + + case ACTION_LIST_METRICS: + case ACTION_DESCRIBE_METRICS: r = output_collected(&context); + break; + + case ACTION_GENERATE: + r = context_generate_report(&context); + break; + + case ACTION_UPLOAD: + r = context_upload_report(&context); + break; + + default: + assert_not_reached(); + } if (r < 0) return r; } diff --git a/src/report/report.h b/src/report/report.h index 3431755233..51d55344f9 100644 --- a/src/report/report.h +++ b/src/report/report.h @@ -9,8 +9,6 @@ #define REPORT_CERT_FILE CERTIFICATE_ROOT "/certs/systemd-report.pem" #define REPORT_TRUST_FILE CERTIFICATE_ROOT "/ca/trusted.pem" -#define REPORT_UPLOAD_DIR "/run/systemd/report.upload" - extern sd_json_format_flags_t arg_json_format_flags; extern char *arg_url, *arg_key, *arg_cert, *arg_trust; extern char **arg_extra_headers; @@ -36,5 +34,3 @@ typedef struct Context { int upload_result; struct iovec_wrapper upload_answer; } Context; - -int report_collected(Context *context); From 3c2f7c6002254fa7108e186aeedf2b2c6a86bd4f Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Sun, 14 Jun 2026 18:46:19 +0200 Subject: [PATCH 4/9] report: add support for optionally signing reports These signatures can be provided by arbitrary Varlink backends symlinked into /run/systemd/report/sign. Also add a proper Varlink IDL for the upload interface, since we are extended it now. --- man/systemd-report.xml | 13 + src/libsystemd/sd-varlink/test-varlink-idl.c | 4 + src/report/meson.build | 1 + src/report/report-generate.c | 19 +- src/report/report-sign.c | 229 ++++++++++++++++++ src/report/report-sign.h | 8 + src/report/report-upload.c | 55 +++-- src/report/report.c | 7 + src/report/report.h | 1 + src/shared/meson.build | 2 + src/shared/varlink-io.systemd.Report.Signer.c | 19 ++ src/shared/varlink-io.systemd.Report.Signer.h | 6 + .../varlink-io.systemd.Report.Uploader.c | 17 ++ .../varlink-io.systemd.Report.Uploader.h | 6 + tmpfiles.d/systemd.conf.in | 1 + 15 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 src/report/report-sign.c create mode 100644 src/report/report-sign.h create mode 100644 src/shared/varlink-io.systemd.Report.Signer.c create mode 100644 src/shared/varlink-io.systemd.Report.Signer.h create mode 100644 src/shared/varlink-io.systemd.Report.Uploader.c create mode 100644 src/shared/varlink-io.systemd.Report.Uploader.h diff --git a/man/systemd-report.xml b/man/systemd-report.xml index 560d21c347..2c3ff710ee 100644 --- a/man/systemd-report.xml +++ b/man/systemd-report.xml @@ -197,6 +197,19 @@ + + + + + If enabled, the report generated by generate or uploaded by + upload is cryptographically signed. In this mode the report is emitted as a + JSON-SEQ stream: the report object comes first, followed by one or more signature objects that + cover the precise binary representation of the report object. Signatures are acquired by calling + io.systemd.Report.Signer.Sign() on any sockets found under + /run/systemd/report.sign/. Defaults to off. + + + diff --git a/src/libsystemd/sd-varlink/test-varlink-idl.c b/src/libsystemd/sd-varlink/test-varlink-idl.c index 65407415fb..0eda928763 100644 --- a/src/libsystemd/sd-varlink/test-varlink-idl.c +++ b/src/libsystemd/sd-varlink/test-varlink-idl.c @@ -43,6 +43,8 @@ #include "varlink-io.systemd.PCRExtend.h" #include "varlink-io.systemd.PCRLock.h" #include "varlink-io.systemd.Repart.h" +#include "varlink-io.systemd.Report.Signer.h" +#include "varlink-io.systemd.Report.Uploader.h" #include "varlink-io.systemd.Resolve.h" #include "varlink-io.systemd.Resolve.Hook.h" #include "varlink-io.systemd.Resolve.Monitor.h" @@ -216,6 +218,8 @@ TEST(parse_format) { &vl_interface_io_systemd_PCRExtend, &vl_interface_io_systemd_PCRLock, &vl_interface_io_systemd_Repart, + &vl_interface_io_systemd_Report_Signer, + &vl_interface_io_systemd_Report_Uploader, &vl_interface_io_systemd_Resolve, &vl_interface_io_systemd_Resolve_Hook, &vl_interface_io_systemd_Resolve_Monitor, diff --git a/src/report/meson.build b/src/report/meson.build index e64f74ee4e..5c6cc41630 100644 --- a/src/report/meson.build +++ b/src/report/meson.build @@ -7,6 +7,7 @@ executables += [ 'sources' : files( 'report.c', 'report-generate.c', + 'report-sign.c', 'report-upload.c', ), }, diff --git a/src/report/report-generate.c b/src/report/report-generate.c index edac44a0ea..69c58e715f 100644 --- a/src/report/report-generate.c +++ b/src/report/report-generate.c @@ -5,6 +5,7 @@ #include "log.h" #include "report.h" #include "report-generate.h" +#include "report-sign.h" #include "time-util.h" int context_build_report(Context *context, sd_json_variant **ret) { @@ -27,6 +28,12 @@ int context_build_report(Context *context, sd_json_variant **ret) { if (r < 0) return log_error_errno(r, "Failed to build JSON data: %m"); + /* Normalize the report, to make signing more robust (note that we sign a specific binary formatting + * of it though, this is hence not load bearing, but still useful) */ + r = sd_json_variant_normalize(&report); + if (r < 0) + return log_error_errno(r, "Failed to normalize report JSON: %m"); + *ret = TAKE_PTR(report); return 0; } @@ -43,9 +50,15 @@ int context_generate_report(Context *context) { if (r < 0) return r; - r = sd_json_variant_dump(report, arg_json_format_flags, /* f= */ NULL, /* prefix= */ NULL); - if (r < 0) - return log_error_errno(r, "Failed to dump json object: %m"); + if (arg_sign) { + r = context_sign_report(context, report, arg_json_format_flags, /* output= */ NULL); + if (r < 0) + return r; + } else { + r = sd_json_variant_dump(report, arg_json_format_flags, /* f= */ NULL, /* prefix= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to dump json object: %m"); + } return 0; } diff --git a/src/report/report-sign.c b/src/report/report-sign.c new file mode 100644 index 0000000000..150547dcf4 --- /dev/null +++ b/src/report/report-sign.c @@ -0,0 +1,229 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "errno-util.h" +#include "json-util.h" +#include "log.h" +#include "memstream-util.h" +#include "path-util.h" +#include "report.h" +#include "report-sign.h" +#include "sha256.h" +#include "time-util.h" +#include "varlink-util.h" + +#define REPORT_SIGN_DIR "/run/systemd/report.sign" +#define REPORT_SIGN_TIMEOUT_USEC USEC_PER_MINUTE + +typedef struct Signature { + char *mechanism; + sd_json_variant *data; +} Signature; + +typedef struct SignatureList { + Signature *signatures; + size_t n_signatures; + int result; +} SignatureList; + +static void signature_done(Signature *s) { + assert(s); + + s->data = sd_json_variant_unref(s->data); + s->mechanism = mfree(s->mechanism); +} + +static void signature_list_done(SignatureList *sl) { + assert(sl); + + FOREACH_ARRAY(s, sl->signatures, sl->n_signatures) + signature_done(s); + + sl->signatures = mfree(sl->signatures); + sl->n_signatures = 0; +} + +static int execute_dir_reply( + sd_varlink *link, + sd_json_variant *reply, + const char *error_id, + sd_varlink_reply_flags_t flags, + void *userdata) { + + SignatureList *sl = ASSERT_PTR(userdata); + int r; + + assert(link); + + /* Get the socket name */ + const char *p = ASSERT_PTR(sd_varlink_get_description(link)); + + _cleanup_free_ char *sn = NULL; + r = path_extract_filename(p, &sn); + if (r < 0) + return log_error_errno(r, "Failed to extract service name from '%s': %m", p); + + if (error_id) { + r = sd_varlink_error_to_errno(error_id, reply); + RET_GATHER(sl->result, r); + return log_error_errno(r, "Signing via Varlink service '%s' failed: %s", p, error_id); + } + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *array = NULL; + + static const sd_json_dispatch_field dispatch_table[] = { + { "data", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_variant, /* offset= */ 0, /* flags= */ 0 }, + {}, + }; + + r = sd_json_dispatch(reply, dispatch_table, /* flags= */ 0, &array); + if (r < 0) + return log_error_errno(r, "Failed to dispatch method reply: %m"); + + size_t n = 0; + if (array) { + sd_json_variant *s; + JSON_VARIANT_ARRAY_FOREACH(s, array) { + if (!GREEDY_REALLOC(sl->signatures, sl->n_signatures + 1)) + return log_oom(); + + Signature *i = sl->signatures + sl->n_signatures; + + i->mechanism = strdup(sn); + if (!i->mechanism) + return log_oom(); + + i->data = sd_json_variant_ref(s); + sl->n_signatures++; + + n++; + } + } + + if (n == 0) + log_info("Mechanism '%s' succeeded, but returned no signatures.", p); + else + log_info("Successfully acquired %zu signatures from '%s'", n, p); + + return 0; +} + +int context_sign_report( + Context *context, + sd_json_variant *report, + sd_json_format_flags_t format_flags, + FILE *output) { + int r; + + assert(context); + assert(report); + + /* When generating a signed report we switch to JSON-SEQ. We'll put the report as first object in the + * stream, and then signature objects after it, that cover the precise binary representation of the + * first object. We normalize the report JSON first, but this is not load bearing, as the signature + * is about the binary representation of the JSON object sent over the wire, not the JSON object + * itself. */ + + if (!output) + output = stdout; + + /* For the report itself we'll use the normalized, dense formatting, in order to make things as + * reproducible as possible. */ + _cleanup_free_ char *text = NULL; + r = sd_json_variant_format(report, SD_JSON_FORMAT_SEQ, &text); + if (r < 0) + return log_error_errno(r, "Failed to format JSON data: %m"); + + uint8_t digest[SHA256_DIGEST_SIZE]; + sha256_direct(text, strlen(text), digest); + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *params = NULL; + r = sd_json_buildo(¶ms, + SD_JSON_BUILD_PAIR_HEX("digest", digest, sizeof(digest)), + SD_JSON_BUILD_PAIR_STRING("algorithm", "SHA256")); + if (r < 0) + return log_error_errno(r, "Failed to build JSON data: %m"); + + _cleanup_(signature_list_done) SignatureList sl = {}; + ssize_t jobs = varlink_execute_directory( + REPORT_SIGN_DIR, + "io.systemd.Report.Signer.Sign", + params, + /* more= */ false, + REPORT_SIGN_TIMEOUT_USEC, + execute_dir_reply, + /* userdata= */ &sl); + if (jobs < 0) + return log_error_errno(jobs, "Failed to execute signing via '%s': %m", REPORT_SIGN_DIR); + if (jobs == 0) + return log_error_errno(SYNTHETIC_ERRNO(ENOPKG), + "No signing mechanism found via '%s'.", REPORT_SIGN_DIR); + if (sl.result < 0) + /* The details were printed at error level by execute_dir_reply above. */ + return log_debug_errno(sl.result, "Signing via '%s' failed: %m", REPORT_SIGN_DIR); + if (sl.n_signatures == 0) + return log_debug_errno(SYNTHETIC_ERRNO(ENOPKG), + "No signatures acquired via '%s'.", REPORT_SIGN_DIR); + + if (fputs(text, output) == EOF) + return log_error_errno(errno, "Failed to write report: %m"); + + /* For the signatures we can use the requested formattting */ + format_flags |= SD_JSON_FORMAT_SEQ; + format_flags &= ~SD_JSON_FORMAT_OFF; + + FOREACH_ARRAY(s, sl.signatures, sl.n_signatures) { + _cleanup_(sd_json_variant_unrefp) sd_json_variant *sig = NULL; + + r = sd_json_buildo(&sig, + SD_JSON_BUILD_PAIR_STRING("mediaType", "application/vnd.io.systemd.report.signature"), + SD_JSON_BUILD_PAIR_STRING("mechanism", s->mechanism), + SD_JSON_BUILD_PAIR_HEX("sha256", digest, sizeof(digest)), + JSON_BUILD_PAIR_VARIANT_NON_EMPTY("data", s->data)); + if (r < 0) + return log_error_errno(r, "Failed to build JSON data: %m"); + + r = sd_json_variant_normalize(&sig); + if (r < 0) + return log_error_errno(r, "Failed to normalize JSON object: %m"); + + r = sd_json_variant_dump(sig, format_flags, output, /* prefix= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to dump json object: %m"); + } + + log_debug("Signing via '%s' finished successfully.", REPORT_SIGN_DIR); + return 0; +} + +int context_sign_report_as_string( + Context *context, + sd_json_variant *report, + sd_json_format_flags_t format_flags, + char **ret) { + + int r; + + assert(context); + assert(report); + assert(ret); + + _cleanup_(memstream_done) MemStream ms = {}; + + FILE *f = memstream_init(&ms); + if (!f) + return log_oom(); + + r = context_sign_report(context, report, format_flags, f); + if (r < 0) + return r; + + r = memstream_finalize(&ms, ret, /* ret_size= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to finalize memory stream: %m"); + + return 0; +} diff --git a/src/report/report-sign.h b/src/report/report-sign.h new file mode 100644 index 0000000000..c5264252b9 --- /dev/null +++ b/src/report/report-sign.h @@ -0,0 +1,8 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "report.h" + +int context_sign_report(Context *context, sd_json_variant *report, sd_json_format_flags_t format_flags, FILE *output); + +int context_sign_report_as_string(Context *context, sd_json_variant *report, sd_json_format_flags_t format_flags, char **ret); diff --git a/src/report/report-upload.c b/src/report/report-upload.c index 17ef327db0..e21c0044e6 100644 --- a/src/report/report-upload.c +++ b/src/report/report-upload.c @@ -7,6 +7,7 @@ #include "log.h" #include "report.h" #include "report-generate.h" +#include "report-sign.h" #include "report-upload.h" #include "string-util.h" #include "strv.h" @@ -57,11 +58,10 @@ static size_t output_callback(char *buf, } #endif -static int http_upload_collected(Context *context, sd_json_variant *report) { +static int http_upload_report(Context *context, sd_json_variant *report) { #if HAVE_LIBCURL _cleanup_(curl_slist_free_allp) struct curl_slist *header = NULL; char error[CURL_ERROR_SIZE] = {}; - _cleanup_free_ char *json = NULL; int r; r = DLOPEN_CURL(LOG_DEBUG, SD_ELF_NOTE_DLOPEN_PRIORITY_REQUIRED); @@ -70,9 +70,16 @@ static int http_upload_collected(Context *context, sd_json_variant *report) { /* Upload a JSON report in text form as a single JSON object, instead of a JSON-SEQ list. */ - r = sd_json_variant_format(report, /* flags= */ 0, &json); - if (r < 0) - return log_error_errno(r, "Failed to format JSON data: %m"); + _cleanup_free_ char *text = NULL; + if (arg_sign) { + r = context_sign_report_as_string(context, report, /* format_flags= */ 0, &text); + if (r < 0) + return r; + } else { + r = sd_json_variant_format(report, /* flags= */ 0, &text); + if (r < 0) + return log_error_errno(r, "Failed to format JSON data: %m"); + } r = curl_append_to_header(&header, STRV_MAKE("Content-Type: application/json", @@ -144,7 +151,7 @@ static int http_upload_collected(Context *context, sd_json_variant *report) { if (!easy_setopt(curl, LOG_ERR, CURLOPT_URL, arg_url)) return -EXFULL; - if (!easy_setopt(curl, LOG_ERR, CURLOPT_POSTFIELDS, json)) + if (!easy_setopt(curl, LOG_ERR, CURLOPT_POSTFIELDS, text)) return -EXFULL; CURLcode code = sym_curl_easy_perform(curl); @@ -218,20 +225,25 @@ static int execute_dir_reply( return 0; } -int context_upload_report(Context *context) { +static int varlink_upload_report(Context *context, sd_json_variant *report) { int r; - _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; - r = context_build_report(context, &report); - if (r < 0) - return r; - - if (arg_url) - return http_upload_collected(context, report); + assert(context); + assert(report); _cleanup_(sd_json_variant_unrefp) sd_json_variant *params = NULL; - r = sd_json_buildo(¶ms, - SD_JSON_BUILD_PAIR_VARIANT("report", report)); + if (arg_sign) { + _cleanup_free_ char *buf = NULL; + + r = context_sign_report_as_string(context, report, /* format_flags= */ 0, &buf); + if (r < 0) + return r; + + r = sd_json_buildo(¶ms, + SD_JSON_BUILD_PAIR_BASE64("reportData", buf, strlen(buf))); + } else + r = sd_json_buildo(¶ms, + SD_JSON_BUILD_PAIR_VARIANT("report", report)); if (r < 0) return log_error_errno(r, "Failed to build JSON data: %m"); @@ -255,3 +267,14 @@ int context_upload_report(Context *context) { log_debug("Upload via %s finished successfully.", REPORT_UPLOAD_DIR); return 0; } + +int context_upload_report(Context *context) { + int r; + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; + r = context_build_report(context, &report); + if (r < 0) + return r; + + return (arg_url ? http_upload_report : varlink_upload_report)(context, report); +} diff --git a/src/report/report.c b/src/report/report.c index c3af61f01d..242cd97bef 100644 --- a/src/report/report.c +++ b/src/report/report.c @@ -43,6 +43,7 @@ char *arg_cert = NULL; char *arg_trust = NULL; char **arg_extra_headers = NULL; usec_t arg_network_timeout_usec = TIMEOUT_USEC; +bool arg_sign = false; STATIC_DESTRUCTOR_REGISTER(arg_matches, strv_freep); STATIC_DESTRUCTOR_REGISTER(arg_url, freep); @@ -874,6 +875,12 @@ static int parse_argv(int argc, char *argv[], char ***ret_args) { if (strv_extend(&arg_extra_headers, opts.arg) < 0) return log_oom(); break; + + OPTION_LONG("sign", "BOOL", "Sign the generated report."): + r = parse_boolean_argument("--sign", opts.arg, &arg_sign); + if (r < 0) + return r; + break; } if ((arg_url || arg_key || arg_cert || arg_trust || arg_extra_headers) && !HAVE_LIBCURL) diff --git a/src/report/report.h b/src/report/report.h index 51d55344f9..0b8873a4ee 100644 --- a/src/report/report.h +++ b/src/report/report.h @@ -13,6 +13,7 @@ extern sd_json_format_flags_t arg_json_format_flags; extern char *arg_url, *arg_key, *arg_cert, *arg_trust; extern char **arg_extra_headers; extern usec_t arg_network_timeout_usec; +extern bool arg_sign; typedef enum Action { ACTION_LIST_METRICS, diff --git a/src/shared/meson.build b/src/shared/meson.build index 9359b4fee0..0dd733100a 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -241,6 +241,8 @@ shared_sources = files( 'varlink-io.systemd.PCRExtend.c', 'varlink-io.systemd.PCRLock.c', 'varlink-io.systemd.Repart.c', + 'varlink-io.systemd.Report.Signer.c', + 'varlink-io.systemd.Report.Uploader.c', 'varlink-io.systemd.Resolve.c', 'varlink-io.systemd.Resolve.Hook.c', 'varlink-io.systemd.Resolve.Monitor.c', diff --git a/src/shared/varlink-io.systemd.Report.Signer.c b/src/shared/varlink-io.systemd.Report.Signer.c new file mode 100644 index 0000000000..4c40285b46 --- /dev/null +++ b/src/shared/varlink-io.systemd.Report.Signer.c @@ -0,0 +1,19 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.Report.Signer.h" + +static SD_VARLINK_DEFINE_METHOD( + Sign, + SD_VARLINK_FIELD_COMMENT("The digest of the data to sign."), + SD_VARLINK_DEFINE_INPUT(digest, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("The digest algorithm used for the digest field above. For now this is always SHA256, but this might be changed eventually."), + SD_VARLINK_DEFINE_INPUT(algorithm, SD_VARLINK_STRING, 0), + SD_VARLINK_FIELD_COMMENT("An array of signature objects"), + SD_VARLINK_DEFINE_OUTPUT(data, SD_VARLINK_OBJECT, SD_VARLINK_ARRAY)); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_Report_Signer, + "io.systemd.Report.Signer", + SD_VARLINK_INTERFACE_COMMENT("Backend API for signing reports. This interface shall be implemented by services linked into /run/systemd/report.sign/."), + SD_VARLINK_SYMBOL_COMMENT("Sign a report, identified by a digest. This may return zero, one or more signatures, as appropriate. For example some backend might have multiple keys or algorithms available, that are appropriate, in which case it can generate multiple signatures."), + &vl_method_Sign); diff --git a/src/shared/varlink-io.systemd.Report.Signer.h b/src/shared/varlink-io.systemd.Report.Signer.h new file mode 100644 index 0000000000..f9a918dbc5 --- /dev/null +++ b/src/shared/varlink-io.systemd.Report.Signer.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_Report_Signer; diff --git a/src/shared/varlink-io.systemd.Report.Uploader.c b/src/shared/varlink-io.systemd.Report.Uploader.c new file mode 100644 index 0000000000..ace9108b2e --- /dev/null +++ b/src/shared/varlink-io.systemd.Report.Uploader.c @@ -0,0 +1,17 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.Report.Uploader.h" + +static SD_VARLINK_DEFINE_METHOD( + Upload, + SD_VARLINK_FIELD_COMMENT("Report data as JSON variant. Either this field or reportData (below) have to be specified. This mode is used if signing is not used, and hence a precise binary formatting of the JSON data is not relevant."), + SD_VARLINK_DEFINE_INPUT(report, SD_VARLINK_OBJECT, SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("Report data in Base64. Either this field or report (above) have to be specified. This mode is used if signing is enabled, as a precise binary formatting of the JSON data is important to authenticate the signature. This data contains a JSON-SEQ compliant stream of objects, the first being the report, the following ones signature objects."), + SD_VARLINK_DEFINE_INPUT(reportData, SD_VARLINK_STRING, SD_VARLINK_NULLABLE)); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_Report_Uploader, + "io.systemd.Report.Uploader", + SD_VARLINK_INTERFACE_COMMENT("Backend API for uploading reports. This interface shall be implemented by services linked into /run/systemd/report.upload/"), + SD_VARLINK_SYMBOL_COMMENT("Upload a report now."), + &vl_method_Upload); diff --git a/src/shared/varlink-io.systemd.Report.Uploader.h b/src/shared/varlink-io.systemd.Report.Uploader.h new file mode 100644 index 0000000000..19e1c78314 --- /dev/null +++ b/src/shared/varlink-io.systemd.Report.Uploader.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_Report_Uploader; diff --git a/tmpfiles.d/systemd.conf.in b/tmpfiles.d/systemd.conf.in index f601cb87f9..786b86d006 100644 --- a/tmpfiles.d/systemd.conf.in +++ b/tmpfiles.d/systemd.conf.in @@ -19,6 +19,7 @@ d$ /run/systemd/users 0755 root root - d /run/systemd/machines 0755 root root - d$ /run/systemd/shutdown 0755 root root - d /run/systemd/dissect-root 0000 root root - +d /run/systemd/report.sign 0700 root root - d /run/log 0755 root root - From 2640a3b0e23f15380226568375a47011adaf35f3 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 08:10:42 +0200 Subject: [PATCH 5/9] report: add a simple software-backed signing backend --- man/rules/meson.build | 5 + man/systemd-report-sign-plain@.service.xml | 66 ++++ presets/90-systemd.preset | 1 + src/report/meson.build | 10 + src/report/report-sign-plain.c | 403 ++++++++++++++++++++ src/shared/crypto-util.c | 3 + units/meson.build | 8 + units/systemd-report-sign-plain.socket | 33 ++ units/systemd-report-sign-plain@.service.in | 21 + 9 files changed, 550 insertions(+) create mode 100644 man/systemd-report-sign-plain@.service.xml create mode 100644 src/report/report-sign-plain.c create mode 100644 units/systemd-report-sign-plain.socket create mode 100644 units/systemd-report-sign-plain@.service.in diff --git a/man/rules/meson.build b/man/rules/meson.build index a3d0650e27..68184c170b 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1185,6 +1185,11 @@ manpages = [ ['systemd-remount-fs.service', '8', ['systemd-remount-fs'], ''], ['systemd-repart', '8', ['systemd-repart.service'], 'ENABLE_REPART'], ['systemd-report', '1', [], ''], + ['systemd-report-sign-plain@.service', + '8', + ['systemd-report-sign-plain', + 'systemd-report-sign-plain.socket'], + 'HAVE_OPENSSL'], ['systemd-resolved.service', '8', ['systemd-resolved'], 'ENABLE_RESOLVE'], ['systemd-rfkill.service', '8', diff --git a/man/systemd-report-sign-plain@.service.xml b/man/systemd-report-sign-plain@.service.xml new file mode 100644 index 0000000000..1602f61663 --- /dev/null +++ b/man/systemd-report-sign-plain@.service.xml @@ -0,0 +1,66 @@ + + + + + + + + systemd-report-sign-plain@.service + systemd + + + + systemd-report-sign-plain@.service + 8 + + + + systemd-report-sign-plain@.service + systemd-report-sign-plain.socket + systemd-report-sign-plain + Sign system reports with a local software key + + + + systemd-report-sign-plain@.service + systemd-report-sign-plain.socket + /usr/lib/systemd/systemd-report-sign-plain + + + + Description + + systemd-report-sign-plain@.service is a system service that signs system reports + generated by + systemd-report1. It is + a signing backend for the logic of that tool: it implements the + io.systemd.Report.Signer.Sign() Varlink method and is reached via a socket linked into + the /run/systemd/report.sign/ directory, named plain. + + The service is socket-activated (one instance per connection) via + systemd-report-sign-plain.socket, and signs the digest passed to it with a local + software key. Signatures are made with the Ed25519 (EdDSA) algorithm. + + On first use a key pair is generated automatically and stored below + /var/lib/systemd/report.sign.plain/: the private key in + local.private (PKCS#8 PEM, readable only by the root user) and the public key in + local.public (PEM, world readable). + + The public key is also exported as a system report metric. + + + + + + See Also + + systemd1 + systemd-report1 + + + + diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index 1dd89f3df9..fc9f55c5d3 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -33,6 +33,7 @@ enable systemd-nsresourced.socket enable systemd-pstore.service enable systemd-report-basic.socket enable systemd-report-cgroup.socket +enable systemd-report-sign-plain.socket enable systemd-resolved.service enable systemd-sysext.service enable systemd-timesyncd.service diff --git a/src/report/meson.build b/src/report/meson.build index 5c6cc41630..c3f1cdadcc 100644 --- a/src/report/meson.build +++ b/src/report/meson.build @@ -27,4 +27,14 @@ executables += [ 'report-cgroup-server.c', ), }, + libexec_template + { + 'name' : 'systemd-report-sign-plain', + 'conditions' : [ + 'HAVE_OPENSSL', + ], + 'sources' : files( + 'report-sign-plain.c', + ), + 'dependencies' : libopenssl_cflags, + }, ] diff --git a/src/report/report-sign-plain.c b/src/report/report-sign-plain.c new file mode 100644 index 0000000000..6aca27810c --- /dev/null +++ b/src/report/report-sign-plain.c @@ -0,0 +1,403 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include +#include + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "build.h" +#include "crypto-util.h" +#include "errno-util.h" +#include "fd-util.h" +#include "fileio.h" +#include "format-table.h" +#include "fs-util.h" +#include "help-util.h" +#include "iovec-util.h" +#include "json-util.h" +#include "lock-util.h" +#include "log.h" +#include "main-func.h" +#include "metrics.h" +#include "options.h" +#include "stat-util.h" +#include "tmpfile-util.h" +#include "varlink-io.systemd.Metrics.h" +#include "varlink-io.systemd.Report.Signer.h" +#include "varlink-util.h" + +#define REPORT_SIGN_PLAIN_DIR "/var/lib/systemd/report.sign.plain" +#define REPORT_SIGN_PLAIN_PRIVATE_KEY "local.private" +#define REPORT_SIGN_PLAIN_PUBLIC_KEY "local.public" + +#define METRIC_IO_SYSTEMD_SIGN_PLAIN_PREFIX "io.systemd.Report.SignPlain." + +static int load_key(int dir_fd, EVP_PKEY **ret) { + _cleanup_fclose_ FILE *f = NULL; + struct stat st; + int r; + + assert(dir_fd >= 0); + assert(ret); + + r = xfopenat(dir_fd, REPORT_SIGN_PLAIN_PRIVATE_KEY, "re", /* open_flags= */ 0, &f); + if (r == -ENOENT) { + *ret = NULL; + return 0; + } + if (r < 0) + return log_error_errno(r, "Failed to open private key file '%s/%s': %m", + REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + if (fstat(fileno(f), &st) < 0) + return log_error_errno(errno, "Failed to stat private key file: %m"); + + r = stat_verify_regular(&st); + if (r < 0) + return log_error_errno(r, "Private key file is not a regular file: %m"); + + if (st.st_uid != 0 || (st.st_mode & 0077) != 0) + return log_error_errno(SYNTHETIC_ERRNO(EPERM), + "Private key file '%s/%s' is accessible by more than the root user, refusing.", + REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = sym_PEM_read_PrivateKey(f, /* x= */ NULL, /* cb= */ NULL, /* u= */ NULL); + if (!pkey) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to load private key from '%s/%s'.", + REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + log_debug("Successfully loaded private key from '%s/%s'.", REPORT_SIGN_PLAIN_DIR, REPORT_SIGN_PLAIN_PRIVATE_KEY); + + *ret = TAKE_PTR(pkey); + return 1; +} + +static int write_key_file(int dir_fd, EVP_PKEY *pkey, bool private_key, const char *name, mode_t mode) { + _cleanup_fclose_ FILE *f = NULL; + _cleanup_free_ char *temp = NULL; + int r; + + assert(dir_fd >= 0); + assert(pkey); + assert(name); + + r = fopen_temporary_at(dir_fd, name, &f, &temp); + if (r < 0) + return log_error_errno(r, "Failed to open key file '%s/%s' for writing: %m", REPORT_SIGN_PLAIN_DIR, name); + + CLEANUP_TMPFILE_AT(dir_fd, temp); + + if (private_key) + r = sym_PEM_write_PrivateKey(f, pkey, /* enc= */ NULL, /* kstr= */ NULL, /* klen= */ 0, /* cb= */ NULL, /* u= */ NULL); + else + r = sym_PEM_write_PUBKEY(f, pkey); + if (r <= 0) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to write %s key.", private_key ? "private" : "public"); + + if (fchmod(fileno(f), mode) < 0) + return log_error_errno(errno, "Failed to adjust key file access mode: %m"); + + r = fflush_sync_and_check(f); + if (r < 0) + return log_error_errno(r, "Failed to write key file: %m"); + + f = safe_fclose(f); + + /* Never overwrite an existing private key: we must not destroy it. The public key is derived from the + * private one and carries no secret, hence may be refreshed unconditionally. */ + if (private_key) + r = rename_noreplace(dir_fd, temp, dir_fd, name); + else + r = RET_NERRNO(renameat(dir_fd, temp, dir_fd, name)); + if (r < 0) { + if (private_key && r == -EEXIST) + return log_error_errno(r, "Private key file '%s/%s' appeared while generating a new one, refusing to overwrite it.", + REPORT_SIGN_PLAIN_DIR, name); + + return log_error_errno(r, "Failed to move key file '%s/%s' into place: %m", REPORT_SIGN_PLAIN_DIR, name); + } + + return 0; +} + +static int generate_key(int dir_fd, EVP_PKEY **ret) { + int r; + + assert(dir_fd >= 0); + assert(ret); + + /* Generate a new Ed25519 key pair. */ + + _cleanup_(EVP_PKEY_CTX_freep) EVP_PKEY_CTX *ctx = sym_EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, /* e= */ NULL); + if (!ctx) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to allocate Ed25519 key generation context."); + + if (sym_EVP_PKEY_keygen_init(ctx) <= 0) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to initialize Ed25519 key generation context."); + + log_info("Generating Ed25519 key pair for signing reports."); + + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = NULL; + if (sym_EVP_PKEY_keygen(ctx, &pkey) <= 0) + return log_error_errno(SYNTHETIC_ERRNO(EIO), "Failed to generate Ed25519 key pair."); + + log_info("Successfully generated Ed25519 key pair."); + + /* Write out the private key (PKCS#8 PEM; this includes the public key too), readable by root only. */ + r = write_key_file(dir_fd, pkey, /* private_key= */ true, REPORT_SIGN_PLAIN_PRIVATE_KEY, 0400); + if (r < 0) + return r; + + /* Write out the public key too, as a convenience for verification, world readable. */ + r = write_key_file(dir_fd, pkey, /* private_key= */ false, REPORT_SIGN_PLAIN_PUBLIC_KEY, 0444); + if (r < 0) + return r; + + r = RET_NERRNO(fsync(dir_fd)); + if (r < 0) + return log_error_errno(r, "Failed to sync directory '%s': %m", REPORT_SIGN_PLAIN_DIR); + + *ret = TAKE_PTR(pkey); + return 1; +} + +static int acquire_key(EVP_PKEY **ret) { + int r; + + assert(ret); + + /* Open (creating if necessary) and exclusively lock the key directory, so that loading/generating the + * key is safe against parallel invocations (we are spawned once per connection). The directory is + * created with mode 0700, so that nobody else can pre-create it with looser permissions. */ + _cleanup_close_ int dir_fd = xopenat_lock_full( + AT_FDCWD, + REPORT_SIGN_PLAIN_DIR, + O_CLOEXEC|O_DIRECTORY|O_CREAT, + /* xopen_flags= */ 0, + /* mode= */ 0700, + LOCK_BSD, + LOCK_EX); + if (dir_fd < 0) + return log_error_errno(dir_fd, "Failed to open and lock directory '%s': %m", REPORT_SIGN_PLAIN_DIR); + + /* First try to load the key off disk. If loading fails for any reason other than the key not + * existing yet (e.g. it is malformed or has bad permissions) we propagate the error and refuse to + * touch the existing file. Only if the key is genuinely missing do we generate a fresh one. */ + r = load_key(dir_fd, ret); + if (r != 0) + return r; + + return generate_key(dir_fd, ret); +} + +static int vl_method_sign( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + static const sd_json_dispatch_field dispatch_table[] = { + { "digest", SD_JSON_VARIANT_STRING, json_dispatch_unhex_iovec, 0, SD_JSON_MANDATORY }, + { "algorithm", SD_JSON_VARIANT_STRING, NULL, 0, SD_JSON_MANDATORY }, + {} + }; + + _cleanup_(iovec_done) struct iovec digest = {}; + int r; + + assert(link); + assert(parameters); + + /* Signing is privileged: we share the Varlink server (and possibly the socket) with the unprivileged + * metrics interface, so we cannot rely on a server-wide access check and must gate this method + * ourselves. Only root (i.e. ourselves) may request signatures. */ + r = varlink_check_privileged_peer(link); + if (r < 0) + return r; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &digest); + if (r != 0) + return r; + + if (!iovec_is_set(&digest)) + return sd_varlink_error_invalid_parameter_name(link, "digest"); + + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = NULL; + r = acquire_key(&pkey); + if (r < 0) + return r; + + /* Sign the provided digest directly. We pass a NULL message digest algorithm, which for a suitable + * signing algorithm such as Ed25519 means the input bytes are signed as-is, without hashing them + * first. The caller already passes us a digest, hence we must not hash it again. */ + _cleanup_(iovec_done) struct iovec signature = {}; + r = digest_and_sign(/* md= */ NULL, pkey, digest.iov_base, digest.iov_len, &signature.iov_base, &signature.iov_len); + if (r < 0) + return log_error_errno(r, "Failed to sign digest: %m"); + + _cleanup_free_ char *pubkey = NULL; + r = openssl_pubkey_to_pem(pkey, &pubkey); + if (r < 0) + return log_error_errno(r, "Failed to extract public key: %m"); + + /* Return a single signature object, carrying the raw Ed25519 (EdDSA) signature and the public key it + * was made with, in PEM form. */ + return sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR( + "data", + SD_JSON_BUILD_ARRAY( + SD_JSON_BUILD_OBJECT( + JSON_BUILD_PAIR_IOVEC_BASE64("signature", &signature), + SD_JSON_BUILD_PAIR_STRING("key", pubkey))))); +} + +static int public_key_generate(const MetricFamily *mf, sd_varlink *link, void *userdata) { + int r; + + assert(mf && mf->name); + assert(link); + + /* Expose the public key the signer uses also as metrics. This has the nice effect that the signature + * will cover the public key, in the sense of a self-signed certificate. We acquire it through the + * very same logic as the Sign() method, i.e. we load it off disk, or generate it on first use if it + * is still missing. */ + _cleanup_(EVP_PKEY_freep) EVP_PKEY *pkey = NULL; + r = acquire_key(&pkey); + if (r < 0) + return r; + + _cleanup_free_ char *pubkey = NULL; + r = openssl_pubkey_to_pem(pkey, &pubkey); + if (r < 0) + return log_error_errno(r, "Failed to extract public key: %m"); + + return metric_build_send_string( + mf, + link, + /* object= */ NULL, + pubkey, + /* fields= */ NULL); +} + +static const MetricFamily metric_family_table[] = { + { + METRIC_IO_SYSTEMD_SIGN_PLAIN_PREFIX "PublicKey", + "PEM-encoded public key used to sign system reports with the local software key", + METRIC_FAMILY_TYPE_STRING, + .generate = public_key_generate, + }, + {} +}; + +static int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return metrics_method_list(metric_family_table, link, parameters, flags, userdata); +} + +static int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + return metrics_method_describe(metric_family_table, link, parameters, flags, userdata); +} + +static int vl_server(void) { + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *vs = NULL; + int r; + + r = DLOPEN_LIBCRYPTO(LOG_ERR, SD_ELF_NOTE_DLOPEN_PRIORITY_REQUIRED); + if (r < 0) + return r; + + /* No server-wide access restriction: the metrics interface is meant to be reachable unprivileged, + * while the signing method enforces root access on its own (see vl_method_sign()). */ + r = varlink_server_new(&vs, /* flags= */ 0, /* userdata= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + /* We serve two interfaces off the same server: the signing API and a metrics provider that exposes our + * public key. Either is reachable regardless of which socket the connection arrived on. */ + r = sd_varlink_server_add_interface_many( + vs, + &vl_interface_io_systemd_Report_Signer, + &vl_interface_io_systemd_Metrics); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interfaces: %m"); + + r = sd_varlink_server_bind_method_many( + vs, + "io.systemd.Report.Signer.Sign", vl_method_sign, + "io.systemd.Metrics.List", vl_method_list_metrics, + "io.systemd.Metrics.Describe", vl_method_describe_metrics); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(vs); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + +static int help(void) { + _cleanup_(table_unrefp) Table *options = NULL; + int r; + + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + help_cmdline("[OPTIONS...]"); + help_abstract("Sign system reports with a local software key."); + help_section("Options"); + + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("systemd-report-sign-plain@.service", "8"); + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + int r; + + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + + FOREACH_OPTION_OR_RETURN(c, &opts) + switch (c) { + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + } + + if (option_parser_get_n_args(&opts) > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program takes no arguments."); + + r = sd_varlink_invocation(SD_VARLINK_ALLOW_ACCEPT); + if (r < 0) + return log_error_errno(r, "Failed to check if invoked in Varlink mode: %m"); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program can only run as a Varlink service."); + return 1; +} + +static int run(int argc, char *argv[]) { + int r; + + log_setup(); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + return vl_server(); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/src/shared/crypto-util.c b/src/shared/crypto-util.c index 5359078f6c..7705436ea3 100644 --- a/src/shared/crypto-util.c +++ b/src/shared/crypto-util.c @@ -1615,6 +1615,9 @@ int digest_and_sign( if (!mdctx) return log_openssl_errors("Failed to create new EVP_MD_CTX"); + /* Note that a NULL 'md' (message digest algorithm) means to sign the provided data directly, without + * hashing it first, as long as a suitable signing algorithm is used that supports this, such as + * Ed25519 (PureEdDSA). For such algorithms callers may pass an already calculated digest as input. */ if (sym_EVP_DigestSignInit(mdctx, NULL, md, NULL, privkey) != 1) { /* Distro security policies often disable support for SHA-1. Let's return a recognizable * error for that case. */ diff --git a/units/meson.build b/units/meson.build index de7a796a1e..af7fcdb8b2 100644 --- a/units/meson.build +++ b/units/meson.build @@ -731,6 +731,14 @@ units = [ }, { 'file' : 'systemd-report-cgroup.socket' }, { 'file' : 'systemd-report-cgroup@.service.in' }, + { + 'file' : 'systemd-report-sign-plain.socket', + 'conditions' : ['HAVE_OPENSSL'], + }, + { + 'file' : 'systemd-report-sign-plain@.service.in', + 'conditions' : ['HAVE_OPENSSL'], + }, { 'file' : 'systemd-resolved.service.in', 'conditions' : ['ENABLE_RESOLVE'], diff --git a/units/systemd-report-sign-plain.socket b/units/systemd-report-sign-plain.socket new file mode 100644 index 0000000000..4a6dea580d --- /dev/null +++ b/units/systemd-report-sign-plain.socket @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Report Signing Socket (Local Software Key) +Documentation=man:systemd-report-sign-plain@.service(8) +DefaultDependencies=no +Before=sockets.target shutdown.target +Conflicts=shutdown.target +WantsMountsFor=/var/lib/systemd/report.sign.plain + +# The same binary serves two interfaces off one socket: an unprivileged metrics provider exposing our public +# key, and the privileged signing API. The actual socket node hence lives in the world-accessible metrics +# directory /run/systemd/report/, and the signing entrypoint is a symlink into the root-only +# /run/systemd/report.sign/ directory. Access to the signing method itself is restricted to root by the +# service (see vl_method_sign()); the directory permissions are merely an additional barrier. +[Socket] +ListenStream=/run/systemd/report/io.systemd.Report.SignPlain +Symlinks=/run/systemd/report.sign/plain +FileDescriptorName=varlink +SocketMode=0666 +Accept=yes +MaxConnectionsPerSource=16 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-report-sign-plain@.service.in b/units/systemd-report-sign-plain@.service.in new file mode 100644 index 0000000000..7778239d78 --- /dev/null +++ b/units/systemd-report-sign-plain@.service.in @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Report Signing Service (Local Software Key) +Documentation=man:systemd-report-sign-plain@.service(8) +DefaultDependencies=no +Conflicts=shutdown.target +Before=shutdown.target +WantsMountsFor=/var/lib/systemd/report.sign.plain + +[Service] +StateDirectory=systemd/report.sign.plain +StateDirectoryMode=0700 +ExecStart={{LIBEXECDIR}}/systemd-report-sign-plain From 9c3e44302148bbd562d69a7b74c33be37fa12e12 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 08:11:32 +0200 Subject: [PATCH 6/9] report: add simple metrics provider that just reads files --- man/rules/meson.build | 5 + man/systemd-report-files@.service.xml | 72 +++++++++ presets/90-systemd.preset | 1 + src/report/meson.build | 7 + src/report/report-files-server.c | 103 +++++++++++++ src/report/report-files.c | 193 +++++++++++++++++++++++++ src/report/report-files.h | 9 ++ units/meson.build | 2 + units/systemd-report-files.socket | 26 ++++ units/systemd-report-files@.service.in | 18 +++ 10 files changed, 436 insertions(+) create mode 100644 man/systemd-report-files@.service.xml create mode 100644 src/report/report-files-server.c create mode 100644 src/report/report-files.c create mode 100644 src/report/report-files.h create mode 100644 units/systemd-report-files.socket create mode 100644 units/systemd-report-files@.service.in diff --git a/man/rules/meson.build b/man/rules/meson.build index 68184c170b..92238b717a 100644 --- a/man/rules/meson.build +++ b/man/rules/meson.build @@ -1185,6 +1185,11 @@ manpages = [ ['systemd-remount-fs.service', '8', ['systemd-remount-fs'], ''], ['systemd-repart', '8', ['systemd-repart.service'], 'ENABLE_REPART'], ['systemd-report', '1', [], ''], + ['systemd-report-files@.service', + '8', + ['systemd-report-files', + 'systemd-report-files.socket'], + ''], ['systemd-report-sign-plain@.service', '8', ['systemd-report-sign-plain', diff --git a/man/systemd-report-files@.service.xml b/man/systemd-report-files@.service.xml new file mode 100644 index 0000000000..9686e46cc1 --- /dev/null +++ b/man/systemd-report-files@.service.xml @@ -0,0 +1,72 @@ + + + + + + + + systemd-report-files@.service + systemd + + + + systemd-report-files@.service + 8 + + + + systemd-report-files@.service + systemd-report-files.socket + systemd-report-files + Report the contents of files as system report metrics + + + + systemd-report-files@.service + systemd-report-files.socket + /usr/lib/systemd/systemd-report-files + + + + Description + + systemd-report-files@.service is a metrics source for + systemd-report1. It + reports the contents of a configurable set of files as metrics, by implementing the + io.systemd.Metrics Varlink interface on a socket linked into the + /run/systemd/report/ directory. + + The files to report are picked up from report.files/ subdirectories of the + usual configuration directories, i.e. from + /etc/systemd/report.files/, + /run/systemd/report.files/, + /var/lib/systemd/report.files/, + /usr/local/lib/systemd/report.files/ and + /usr/lib/systemd/report.files/. Typically these directories contain symlinks pointing + to the actual files in the file system whose contents shall be reported. Entries with the same name in an + earlier directory override those in a later one. + + Each entry is reported as a metric named + io.systemd.Files.NAME, where + NAME is the name of the entry (which must be a valid metric field name). The + metric value is the verbatim contents of the file. Files that cannot be read (for example because a + symlink is dangling) are skipped, as are files whose contents are not valid UTF-8 text. + + Note that access to this service is unprivileged, but it runs privileged. This means take care when + symlinking files into the various report.files/ directories: this service might + provide read access even if the client does not have the required direct access rights itself. + + + + + + See Also + + systemd1 + systemd-report1 + + + + diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index fc9f55c5d3..658fa1ce60 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -33,6 +33,7 @@ enable systemd-nsresourced.socket enable systemd-pstore.service enable systemd-report-basic.socket enable systemd-report-cgroup.socket +enable systemd-report-files.socket enable systemd-report-sign-plain.socket enable systemd-resolved.service enable systemd-sysext.service diff --git a/src/report/meson.build b/src/report/meson.build index c3f1cdadcc..c3e7cf959e 100644 --- a/src/report/meson.build +++ b/src/report/meson.build @@ -27,6 +27,13 @@ executables += [ 'report-cgroup-server.c', ), }, + libexec_template + { + 'name' : 'systemd-report-files', + 'sources' : files( + 'report-files.c', + 'report-files-server.c', + ), + }, libexec_template + { 'name' : 'systemd-report-sign-plain', 'conditions' : [ diff --git a/src/report/report-files-server.c b/src/report/report-files-server.c new file mode 100644 index 0000000000..c1f90ccd7b --- /dev/null +++ b/src/report/report-files-server.c @@ -0,0 +1,103 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-varlink.h" + +#include "build.h" +#include "format-table.h" +#include "help-util.h" +#include "log.h" +#include "main-func.h" +#include "options.h" +#include "report-files.h" +#include "varlink-io.systemd.Metrics.h" +#include "varlink-util.h" + +static int vl_server(void) { + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *vs = NULL; + int r; + + r = varlink_server_new(&vs, /* flags= */ 0, /* userdata= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + r = sd_varlink_server_add_interface(vs, &vl_interface_io_systemd_Metrics); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interface: %m"); + + r = sd_varlink_server_bind_method_many( + vs, + "io.systemd.Metrics.List", vl_method_list_metrics, + "io.systemd.Metrics.Describe", vl_method_describe_metrics); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(vs); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + +static int help(void) { + _cleanup_(table_unrefp) Table *options = NULL; + int r; + + r = option_parser_get_help_table(&options); + if (r < 0) + return r; + + help_cmdline("[OPTIONS...]"); + help_abstract("Report the contents of files as system report metrics."); + help_section("Options"); + + r = table_print_or_warn(options); + if (r < 0) + return r; + + help_man_page_reference("systemd-report-files@.service", "8"); + return 0; +} + +static int parse_argv(int argc, char *argv[]) { + int r; + + assert(argc >= 0); + assert(argv); + + OptionParser opts = { argc, argv }; + + FOREACH_OPTION_OR_RETURN(c, &opts) + switch (c) { + OPTION_COMMON_HELP: + return help(); + + OPTION_COMMON_VERSION: + return version(); + } + + if (option_parser_get_n_args(&opts) > 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program takes no arguments."); + + r = sd_varlink_invocation(SD_VARLINK_ALLOW_ACCEPT); + if (r < 0) + return log_error_errno(r, "Failed to check if invoked in Varlink mode: %m"); + if (r == 0) + return log_error_errno(SYNTHETIC_ERRNO(EINVAL), + "This program can only run as a Varlink service."); + return 1; +} + +static int run(int argc, char *argv[]) { + int r; + + log_setup(); + + r = parse_argv(argc, argv); + if (r <= 0) + return r; + + return vl_server(); +} + +DEFINE_MAIN_FUNCTION(run); diff --git a/src/report/report-files.c b/src/report/report-files.c new file mode 100644 index 0000000000..af2c38b5fe --- /dev/null +++ b/src/report/report-files.c @@ -0,0 +1,193 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "sd-json.h" +#include "sd-varlink.h" + +#include "alloc-util.h" +#include "chase.h" +#include "conf-files.h" +#include "fd-util.h" +#include "fileio.h" +#include "log.h" +#include "metrics.h" +#include "path-util.h" +#include "report-files.h" +#include "string-util.h" +#include "strv.h" +#include "utf8.h" +#include "varlink-idl-util.h" + +/* Upper bounds, to protect against pathologically large directories or files. */ +#define REPORT_FILES_MAX 1024U +#define REPORT_FILE_SIZE_MAX (4U * 1024U * 1024U) + +/* The directories we look for files to report in. This is the usual CONF_PATHS() set (/etc/, /run/, + * /usr/local/lib/, /usr/lib/), plus an extra directory below /var/lib/ for persistent local additions. Files + * (typically symlinks to the actual files to report) dropped into any of these are reported as metrics, keyed + * by their name. Entries in earlier directories override identically named ones in later directories. */ +static const char* const report_files_dirs[] = { + "/etc/systemd/report.files", + "/run/systemd/report.files", + "/var/lib/systemd/report.files", + "/usr/local/lib/systemd/report.files", + "/usr/lib/systemd/report.files", + NULL, +}; + +static MetricFamily* metric_family_array_free(MetricFamily *families) { + if (!families) + return NULL; + + /* The array is NULL-name terminated. We own the name/description strings. */ + for (MetricFamily *mf = families; mf->name; mf++) { + free((char*) mf->name); + free((char*) mf->description); + } + + return mfree(families); +} + +DEFINE_TRIVIAL_CLEANUP_FUNC(MetricFamily*, metric_family_array_free); + +static int file_metric_generate(const MetricFamily *mf, sd_varlink *link, void *userdata) { + int r; + + assert(mf && mf->name); + assert(link); + + /* Recover the file name from the metric family name: it is the part following the interface prefix. + * We look it up across our directories again (rather than caching the path found while building the + * metric list), and read the first instance we find, matching the precedence used at enumeration. */ + const char *field = startswith(mf->name, METRIC_IO_SYSTEMD_FILES_PREFIX); + assert(field); + + _cleanup_free_ char *buf = NULL, *discovered_path = NULL; + size_t size = 0; + STRV_FOREACH(d, report_files_dirs) { + _cleanup_free_ char *path = path_join(*d, field); + if (!path) + return log_oom(); + + _cleanup_free_ char *resolved = NULL; + _cleanup_close_ int fd = chase_and_open(path, /* root= */ NULL, CHASE_MUST_BE_REGULAR, O_RDONLY|O_CLOEXEC, &resolved); + if (fd == -ENOENT) /* Not in this directory (or dangling symlink): try the next one. */ + continue; + if (fd < 0) { + log_warning_errno(fd, "Failed to open '%s', skipping: %m", path); + return 0; + } + + r = read_full_file_full( + fd, + /* filename= */ NULL, + /* offset= */ UINT64_MAX, + REPORT_FILE_SIZE_MAX, + READ_FULL_FILE_FAIL_WHEN_LARGER, + /* bind_name= */ NULL, + &buf, + &size); + if (r < 0) { + log_warning_errno(r, "Failed to read '%s', skipping: %m", path); + return 0; + } + + discovered_path = TAKE_PTR(resolved); + break; + } + + if (!discovered_path) { + log_debug("File for metric '%s' unavailable, skipping.", mf->name); + return 0; + } + + /* Metric values are JSON strings, so we can only report text files. Skip anything that isn't valid, + * NUL-free UTF-8. */ + if (memchr(buf, 0, size) || !utf8_is_valid(buf)) { + log_debug("File for metric '%s' is not valid UTF-8 text, skipping.", mf->name); + return 0; + } + + return metric_build_send_string(mf, link, discovered_path, buf, /* fields= */ NULL); +} + +static int build_file_metrics(MetricFamily **ret) { + _cleanup_(metric_family_array_freep) MetricFamily *families = NULL; + _cleanup_strv_free_ char **files = NULL; + size_t n = 0; + int r; + + assert(ret); + + /* Enumerate the files to report across all our directories, deduplicated by name. The entry name is + * used as the metric field name. */ + r = conf_files_list_strv(&files, /* suffix= */ NULL, /* root= */ NULL, CONF_FILES_REGULAR, report_files_dirs); + if (r < 0) + return log_error_errno(r, "Failed to enumerate report files: %m"); + + STRV_FOREACH(f, files) { + _cleanup_free_ char *base = NULL; + r = path_extract_filename(*f, &base); + if (r < 0) + return log_error_errno(r, "Failed to extract file name from '%s': %m", *f); + + /* The name becomes the metric field name, so it must be a valid one. */ + if (!varlink_idl_field_name_is_valid(base)) { + log_debug("Report file '%s' does not have a valid metric field name, skipping.", *f); + continue; + } + + if (n >= REPORT_FILES_MAX) { + log_warning("More than %u report files found, not reporting the rest.", REPORT_FILES_MAX); + break; + } + + _cleanup_free_ char *name = strjoin(METRIC_IO_SYSTEMD_FILES_PREFIX, base); + _cleanup_free_ char *description = strjoin("Contents of the '", base, "' report file"); + if (!name || !description) + return log_oom(); + + /* Room for the new entry plus the NULL-name terminator. */ + if (!GREEDY_REALLOC(families, n + 2)) + return log_oom(); + + families[n++] = (MetricFamily) { + .name = TAKE_PTR(name), + .description = TAKE_PTR(description), + .type = METRIC_FAMILY_TYPE_STRING, + .generate = file_metric_generate, + }; + families[n] = (MetricFamily) {}; /* terminator */ + } + + /* The metrics helpers expect a valid, terminated array even when empty. */ + if (!families) { + families = new0(MetricFamily, 1); + if (!families) + return log_oom(); + } + + *ret = TAKE_PTR(families); + return 0; +} + +int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + int r; + + _cleanup_(metric_family_array_freep) MetricFamily *families = NULL; + r = build_file_metrics(&families); + if (r < 0) + return r; + + return metrics_method_list(families, link, parameters, flags, /* userdata= */ NULL); +} + +int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata) { + int r; + + _cleanup_(metric_family_array_freep) MetricFamily *families = NULL; + r = build_file_metrics(&families); + if (r < 0) + return r; + + return metrics_method_describe(families, link, parameters, flags, /* userdata= */ NULL); +} diff --git a/src/report/report-files.h b/src/report/report-files.h new file mode 100644 index 0000000000..0bdee8cb97 --- /dev/null +++ b/src/report/report-files.h @@ -0,0 +1,9 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "shared-forward.h" + +#define METRIC_IO_SYSTEMD_FILES_PREFIX "io.systemd.Files." + +int vl_method_list_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); +int vl_method_describe_metrics(sd_varlink *link, sd_json_variant *parameters, sd_varlink_method_flags_t flags, void *userdata); diff --git a/units/meson.build b/units/meson.build index af7fcdb8b2..ae2e2c8ccf 100644 --- a/units/meson.build +++ b/units/meson.build @@ -731,6 +731,8 @@ units = [ }, { 'file' : 'systemd-report-cgroup.socket' }, { 'file' : 'systemd-report-cgroup@.service.in' }, + { 'file' : 'systemd-report-files.socket' }, + { 'file' : 'systemd-report-files@.service.in' }, { 'file' : 'systemd-report-sign-plain.socket', 'conditions' : ['HAVE_OPENSSL'], diff --git a/units/systemd-report-files.socket b/units/systemd-report-files.socket new file mode 100644 index 0000000000..f37c508cf6 --- /dev/null +++ b/units/systemd-report-files.socket @@ -0,0 +1,26 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Report Files Metrics Socket +Documentation=man:systemd-report-files@.service(8) +DefaultDependencies=no +Before=sockets.target shutdown.target +Conflicts=shutdown.target + +[Socket] +ListenStream=/run/systemd/report/io.systemd.Files +FileDescriptorName=varlink +SocketMode=0666 +Accept=yes +MaxConnectionsPerSource=16 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-report-files@.service.in b/units/systemd-report-files@.service.in new file mode 100644 index 0000000000..ae33d5d735 --- /dev/null +++ b/units/systemd-report-files@.service.in @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Report Files Metrics +Documentation=man:systemd-report-files@.service(8) +DefaultDependencies=no +Conflicts=shutdown.target +Before=shutdown.target + +[Service] +ExecStart={{LIBEXECDIR}}/systemd-report-files From 7683c3281ce11c1fce2e6b1000f5efef6c931ff7 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 10:06:03 +0200 Subject: [PATCH 7/9] report: turn systemd-report itself into a varlink service Add a Varlink API for requesting a report. --- presets/90-systemd.preset | 1 + src/libsystemd/sd-varlink/test-varlink-idl.c | 2 + src/report/report.c | 246 +++++++++++++++---- src/report/report.h | 3 +- src/shared/meson.build | 1 + src/shared/varlink-io.systemd.Report.c | 26 ++ src/shared/varlink-io.systemd.Report.h | 6 + units/meson.build | 2 + units/systemd-report.socket | 24 ++ units/systemd-report@.service.in | 18 ++ 10 files changed, 274 insertions(+), 55 deletions(-) create mode 100644 src/shared/varlink-io.systemd.Report.c create mode 100644 src/shared/varlink-io.systemd.Report.h create mode 100644 units/systemd-report.socket create mode 100644 units/systemd-report@.service.in diff --git a/presets/90-systemd.preset b/presets/90-systemd.preset index 658fa1ce60..a00643f684 100644 --- a/presets/90-systemd.preset +++ b/presets/90-systemd.preset @@ -35,6 +35,7 @@ enable systemd-report-basic.socket enable systemd-report-cgroup.socket enable systemd-report-files.socket enable systemd-report-sign-plain.socket +enable systemd-report.socket enable systemd-resolved.service enable systemd-sysext.service enable systemd-timesyncd.service diff --git a/src/libsystemd/sd-varlink/test-varlink-idl.c b/src/libsystemd/sd-varlink/test-varlink-idl.c index 0eda928763..e026d4b07e 100644 --- a/src/libsystemd/sd-varlink/test-varlink-idl.c +++ b/src/libsystemd/sd-varlink/test-varlink-idl.c @@ -43,6 +43,7 @@ #include "varlink-io.systemd.PCRExtend.h" #include "varlink-io.systemd.PCRLock.h" #include "varlink-io.systemd.Repart.h" +#include "varlink-io.systemd.Report.h" #include "varlink-io.systemd.Report.Signer.h" #include "varlink-io.systemd.Report.Uploader.h" #include "varlink-io.systemd.Resolve.h" @@ -218,6 +219,7 @@ TEST(parse_format) { &vl_interface_io_systemd_PCRExtend, &vl_interface_io_systemd_PCRLock, &vl_interface_io_systemd_Repart, + &vl_interface_io_systemd_Report, &vl_interface_io_systemd_Report_Signer, &vl_interface_io_systemd_Report_Uploader, &vl_interface_io_systemd_Resolve, diff --git a/src/report/report.c b/src/report/report.c index 242cd97bef..c3b014a008 100644 --- a/src/report/report.c +++ b/src/report/report.c @@ -17,6 +17,7 @@ #include "recurse-dir.h" #include "report.h" #include "report-generate.h" +#include "report-sign.h" #include "report-upload.h" #include "runtime-scope.h" #include "set.h" @@ -25,6 +26,8 @@ #include "strv.h" #include "time-util.h" #include "varlink-idl-util.h" +#include "varlink-io.systemd.Report.h" +#include "varlink-util.h" #include "verbs.h" #include "web-util.h" @@ -35,7 +38,6 @@ static PagerFlags arg_pager_flags = 0; static bool arg_legend = true; static RuntimeScope arg_runtime_scope = RUNTIME_SCOPE_SYSTEM; -static char **arg_matches = NULL; sd_json_format_flags_t arg_json_format_flags = SD_JSON_FORMAT_OFF|SD_JSON_FORMAT_PRETTY_AUTO|SD_JSON_FORMAT_COLOR_AUTO; char *arg_url = NULL; char *arg_key = NULL; @@ -45,7 +47,6 @@ char **arg_extra_headers = NULL; usec_t arg_network_timeout_usec = TIMEOUT_USEC; bool arg_sign = false; -STATIC_DESTRUCTOR_REGISTER(arg_matches, strv_freep); STATIC_DESTRUCTOR_REGISTER(arg_url, freep); STATIC_DESTRUCTOR_REGISTER(arg_key, freep); STATIC_DESTRUCTOR_REGISTER(arg_cert, freep); @@ -73,6 +74,7 @@ static void context_done(Context *context) { context->event = sd_event_unref(context->event); context->link_infos = set_free(context->link_infos); + context->matches = strv_free(context->matches); sd_json_variant_unref_many(context->metrics, context->n_metrics); context->metrics = NULL; context->n_metrics = 0; @@ -193,13 +195,13 @@ static Verdict metrics_verdict(LinkInfo *li, sd_json_variant *metric) { /* Check it against any specified matches */ bool matches; - if (strv_isempty(arg_matches)) + if (strv_isempty(li->context->matches)) matches = true; else { matches = false; /* Allow exact matches or prefix matches */ - STRV_FOREACH(i, arg_matches) + STRV_FOREACH(i, li->context->matches) if (streq(metric_name, *i) || metric_startswith_prefix(metric_name, *i)) { matches = true; @@ -493,28 +495,33 @@ static int output_collected(Context *context) { return 0; } -static int parse_metrics_matches(char **matches) { +static int parse_metrics_matches(char **input, char ***ret) { int r; - STRV_FOREACH(i, matches) { + assert(ret); + + _cleanup_strv_free_ char **matches = NULL; + STRV_FOREACH(i, input) { r = metrics_name_valid(*i); if (r < 0) return log_error_errno(r, "Failed to determine if '%s' is a valid metric name: %m", *i); if (!r && !varlink_idl_interface_name_is_valid(*i)) return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Match is not a valid family name or prefix: %s", *i); - if (strv_extend(&arg_matches, *i) < 0) + if (strv_extend(&matches, *i) < 0) return log_oom(); } - strv_sort_uniq(arg_matches); + strv_sort_uniq(matches); + + *ret = TAKE_PTR(matches); return 0; } -static bool test_service_matches(const char *service) { +static bool test_service_matches(const char *service, char **matches) { assert(service); - if (strv_isempty(arg_matches)) + if (strv_isempty(matches)) return true; /* Only contact services whose name is either a prefix of any of the specified metrics families, or @@ -527,7 +534,7 @@ static bool test_service_matches(const char *service) { * it should also be fine to specify a full metric name, and then go directly to the relevant services, and ask for matching metrics. */ - STRV_FOREACH(i, arg_matches) { + STRV_FOREACH(i, matches) { if (streq(service, *i)) return true; @@ -539,7 +546,7 @@ static bool test_service_matches(const char *service) { return false; } -static int readdir_sources(char **ret_directory, DirectoryEntries **ret) { +static int readdir_sources(char **matches, char **ret_directory, DirectoryEntries **ret) { int r; assert(ret_directory); @@ -575,7 +582,7 @@ static int readdir_sources(char **ret_directory, DirectoryEntries **ret) { if (!varlink_idl_interface_name_is_valid(d->d_name)) continue; - if (!test_service_matches(d->d_name)) + if (!test_service_matches(d->d_name, matches)) continue; de->entries[m++] = *i; @@ -589,6 +596,51 @@ static int readdir_sources(char **ret_directory, DirectoryEntries **ret) { return m > 0; } +static int context_collect_metrics(Context *context) { + int r; + + /* Contacts all known metrics sources, issues the appropriate Varlink call on each and runs the + * event loop until all replies came in. Expects the caller to have set up context->event + * beforehand. The collected metrics end up in context->metrics. */ + + assert(context); + assert(context->event); + + _cleanup_free_ DirectoryEntries *de = NULL; + _cleanup_free_ char *sources_path = NULL; + r = readdir_sources(context->matches, &sources_path, &de); + if (r < 0) + return r; + if (r == 0) + return 0; + + FOREACH_ARRAY(i, de->entries, de->n_entries) { + struct dirent *d = *i; + + if (set_size(context->link_infos) >= METRICS_LINKS_MAX) { + context->n_skipped_sources++; + break; + } + + _cleanup_free_ char *p = path_join(sources_path, d->d_name); + if (!p) + return log_oom(); + + (void) call_collect(context, d->d_name, p); + } + + context->n_contacted_sources = set_size(context->link_infos); + + if (context->n_contacted_sources == 0) + return 0; + + r = sd_event_loop(context->event); + if (r < 0) + return log_error_errno(r, "Failed to run event loop: %m"); + + return 1; +} + VERB_FULL(verb_metrics, "metrics", "[MATCH…]", VERB_ANY, VERB_ANY, 0, ACTION_LIST_METRICS, "Acquire list of metrics and their values"); VERB_FULL(verb_metrics, "describe", "[MATCH…]", VERB_ANY, VERB_ANY, 0, ACTION_DESCRIBE_METRICS, @@ -610,55 +662,29 @@ static int verb_metrics(int argc, char *argv[], uintptr_t data, void *userdata) * objects. In the report format, we return a single JSON object, so don't do this. */ arg_json_format_flags |= SD_JSON_FORMAT_SEQ; - r = parse_metrics_matches(argv + 1); - if (r < 0) - return r; - _cleanup_(context_done) Context context = { .action = action, }; - size_t n_skipped_sources = 0; - _cleanup_free_ DirectoryEntries *de = NULL; - _cleanup_free_ char *sources_path = NULL; - r = readdir_sources(&sources_path, &de); + r = parse_metrics_matches(argv + 1, &context.matches); if (r < 0) return r; - if (r > 0) { - r = sd_event_default(&context.event); - if (r < 0) - return log_error_errno(r, "Failed to get event loop: %m"); - r = sd_event_set_signal_exit(context.event, true); - if (r < 0) - return log_error_errno(r, "Failed to enable exit on SIGINT/SIGTERM: %m"); + r = sd_event_default(&context.event); + if (r < 0) + return log_error_errno(r, "Failed to get event loop: %m"); - FOREACH_ARRAY(i, de->entries, de->n_entries) { - struct dirent *d = *i; + r = sd_event_set_signal_exit(context.event, true); + if (r < 0) + return log_error_errno(r, "Failed to enable exit on SIGINT/SIGTERM: %m"); - if (set_size(context.link_infos) >= METRICS_LINKS_MAX) { - n_skipped_sources++; - break; - } - - _cleanup_free_ char *p = path_join(sources_path, d->d_name); - if (!p) - return log_oom(); - - (void) call_collect(&context, d->d_name, p); - } - } - - if (set_isempty(context.link_infos)) { + r = context_collect_metrics(&context); + if (r < 0) + return r; + if (r == 0) { if (arg_legend) log_info("No metrics sources found."); } else { - assert(context.event); - - r = sd_event_loop(context.event); - if (r < 0) - return log_error_errno(r, "Failed to run event loop: %m"); - switch (action) { case ACTION_LIST_METRICS: @@ -681,10 +707,10 @@ static int verb_metrics(int argc, char *argv[], uintptr_t data, void *userdata) return r; } - if (n_skipped_sources > 0) + if (context.n_skipped_sources > 0) return log_warning_errno(SYNTHETIC_ERRNO(EUCLEAN), - "Too many metrics sources, only %u sources contacted, %zu sources skipped.", - set_size(context.link_infos), n_skipped_sources); + "Too many metrics sources, only %zu sources contacted, %zu sources skipped.", + context.n_contacted_sources, context.n_skipped_sources); if (context.n_invalid_metrics > 0) return log_warning_errno(SYNTHETIC_ERRNO(EUCLEAN), "%zu metrics are not valid.", @@ -706,7 +732,7 @@ static int verb_list_sources(int argc, char *argv[], uintptr_t _data, void *user _cleanup_free_ char *sources_path = NULL; _cleanup_free_ DirectoryEntries *de = NULL; - r = readdir_sources(&sources_path, &de); + r = readdir_sources(/* matches= */ NULL, &sources_path, &de); if (r < 0) return r; if (r > 0) @@ -752,6 +778,110 @@ static int verb_list_sources(int argc, char *argv[], uintptr_t _data, void *user return 0; } +static int vl_method_generate_internal( + sd_varlink *link, + sd_json_variant *parameters, + bool sign) { + + int r; + + assert(link); + assert(parameters); + + _cleanup_strv_free_ char **input_matches = NULL; + + static const sd_json_dispatch_field dispatch_table[] = { + { "matches", SD_JSON_VARIANT_ARRAY, sd_json_dispatch_strv, 0, 0 }, + {} + }; + + r = sd_varlink_dispatch(link, parameters, dispatch_table, &input_matches); + if (r != 0) + return r; + + _cleanup_(context_done) Context context = { + .action = ACTION_GENERATE, + }; + + r = parse_metrics_matches(input_matches, &context.matches); + if (r < 0) + return sd_varlink_error_invalid_parameter_name(link, "matches"); + + r = sd_event_new(&context.event); + if (r < 0) + return log_error_errno(r, "Failed to allocate event loop: %m"); + + r = context_collect_metrics(&context); + if (r < 0) + return r; + + _cleanup_(sd_json_variant_unrefp) sd_json_variant *report = NULL; + r = context_build_report(&context, &report); + if (r < 0) + return r; + + if (sign) { + /* Use compact JSON formatting (no pretty/color/seq flags), matching the on-the-wire format + * used for uploads. context_sign_report() adds the JSON-SEQ record separators itself. */ + _cleanup_free_ char *s = NULL; + r = context_sign_report_as_string(&context, report, /* format_flags= */ 0, &s); + if (r < 0) + return r; + + return sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_BASE64("reportData", s, strlen(s))); + } + + return sd_varlink_replybo( + link, + SD_JSON_BUILD_PAIR_VARIANT("report", report)); +} + +static int vl_method_generate( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + return vl_method_generate_internal(link, parameters, /* sign= */ false); +} + +static int vl_method_generate_signed( + sd_varlink *link, + sd_json_variant *parameters, + sd_varlink_method_flags_t flags, + void *userdata) { + + return vl_method_generate_internal(link, parameters, /* sign= */ true); +} + +static int vl_server(void) { + _cleanup_(sd_varlink_server_unrefp) sd_varlink_server *vs = NULL; + int r; + + r = varlink_server_new(&vs, SD_VARLINK_SERVER_MYSELF_ONLY|SD_VARLINK_SERVER_ROOT_ONLY, /* userdata= */ NULL); + if (r < 0) + return log_error_errno(r, "Failed to allocate Varlink server: %m"); + + r = sd_varlink_server_add_interface(vs, &vl_interface_io_systemd_Report); + if (r < 0) + return log_error_errno(r, "Failed to add Varlink interface: %m"); + + r = sd_varlink_server_bind_method_many( + vs, + "io.systemd.Report.Generate", vl_method_generate, + "io.systemd.Report.GenerateSigned", vl_method_generate_signed); + if (r < 0) + return log_error_errno(r, "Failed to bind Varlink methods: %m"); + + r = sd_varlink_server_loop_auto(vs); + if (r < 0) + return log_error_errno(r, "Failed to run Varlink event loop: %m"); + + return 0; +} + static int help(void) { int r; @@ -896,6 +1026,14 @@ static int run(int argc, char *argv[]) { log_setup(); + /* If invoked as a socket-activated Varlink service (Accept=yes), act as the io.systemd.Report + * server instead of running the command line interface. */ + r = sd_varlink_invocation(SD_VARLINK_ALLOW_ACCEPT); + if (r < 0) + return log_error_errno(r, "Failed to check if invoked in Varlink mode: %m"); + if (r > 0) + return vl_server(); + r = parse_argv(argc, argv, &args); if (r <= 0) return r; diff --git a/src/report/report.h b/src/report/report.h index 0b8873a4ee..177491d0d7 100644 --- a/src/report/report.h +++ b/src/report/report.h @@ -29,8 +29,9 @@ typedef struct Context { Action action; sd_event *event; Set *link_infos; + char **matches; /* Metric families to include, or NULL/empty for all */ sd_json_variant **metrics; /* Collected metrics for sorting */ - size_t n_metrics, n_skipped_metrics, n_invalid_metrics; + size_t n_metrics, n_skipped_metrics, n_invalid_metrics, n_contacted_sources, n_skipped_sources; int upload_result; struct iovec_wrapper upload_answer; diff --git a/src/shared/meson.build b/src/shared/meson.build index 0dd733100a..a3684ade1e 100644 --- a/src/shared/meson.build +++ b/src/shared/meson.build @@ -241,6 +241,7 @@ shared_sources = files( 'varlink-io.systemd.PCRExtend.c', 'varlink-io.systemd.PCRLock.c', 'varlink-io.systemd.Repart.c', + 'varlink-io.systemd.Report.c', 'varlink-io.systemd.Report.Signer.c', 'varlink-io.systemd.Report.Uploader.c', 'varlink-io.systemd.Resolve.c', diff --git a/src/shared/varlink-io.systemd.Report.c b/src/shared/varlink-io.systemd.Report.c new file mode 100644 index 0000000000..38f2f780bd --- /dev/null +++ b/src/shared/varlink-io.systemd.Report.c @@ -0,0 +1,26 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ + +#include "varlink-io.systemd.Report.h" + +static SD_VARLINK_DEFINE_METHOD( + Generate, + SD_VARLINK_FIELD_COMMENT("Selects which metrics to include in the report, as an array of metric family names or prefixes thereof. If unset or empty all available metrics are included. This matches the [MATCH…] arguments of the systemd-report command line tool."), + SD_VARLINK_DEFINE_INPUT(matches, SD_VARLINK_STRING, SD_VARLINK_ARRAY|SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The generated report as a JSON object. This mode does not sign the report, hence a precise binary formatting of the JSON data is not relevant."), + SD_VARLINK_DEFINE_OUTPUT(report, SD_VARLINK_OBJECT, 0)); + +static SD_VARLINK_DEFINE_METHOD( + GenerateSigned, + SD_VARLINK_FIELD_COMMENT("Selects which metrics to include in the report, as an array of metric family names or prefixes thereof. If unset or empty all available metrics are included. This matches the [MATCH…] arguments of the systemd-report command line tool."), + SD_VARLINK_DEFINE_INPUT(matches, SD_VARLINK_STRING, SD_VARLINK_ARRAY|SD_VARLINK_NULLABLE), + SD_VARLINK_FIELD_COMMENT("The generated, signed report in Base64. A precise binary formatting of the JSON data is important to authenticate the signature. This data contains a JSON-SEQ compliant stream of objects, the first being the report, the following ones signature objects."), + SD_VARLINK_DEFINE_OUTPUT(reportData, SD_VARLINK_STRING, 0)); + +SD_VARLINK_DEFINE_INTERFACE( + io_systemd_Report, + "io.systemd.Report", + SD_VARLINK_INTERFACE_COMMENT("Frontend API for generating system reports. This interface is implemented by systemd-report, which aggregates the metrics exposed by the io.systemd.Metrics services linked into /run/systemd/report/."), + SD_VARLINK_SYMBOL_COMMENT("Generate a report and return it as a JSON object."), + &vl_method_Generate, + SD_VARLINK_SYMBOL_COMMENT("Generate a signed report and return it Base64 encoded."), + &vl_method_GenerateSigned); diff --git a/src/shared/varlink-io.systemd.Report.h b/src/shared/varlink-io.systemd.Report.h new file mode 100644 index 0000000000..8585613428 --- /dev/null +++ b/src/shared/varlink-io.systemd.Report.h @@ -0,0 +1,6 @@ +/* SPDX-License-Identifier: LGPL-2.1-or-later */ +#pragma once + +#include "sd-varlink-idl.h" + +extern const sd_varlink_interface vl_interface_io_systemd_Report; diff --git a/units/meson.build b/units/meson.build index ae2e2c8ccf..11b73b9b1c 100644 --- a/units/meson.build +++ b/units/meson.build @@ -633,6 +633,8 @@ units = [ 'conditions' : ['ENABLE_BOOTLOADER', 'HAVE_OPENSSL', 'HAVE_TPM2'], 'symlinks' : ['sysinit.target.wants/'], }, + { 'file' : 'systemd-report.socket' }, + { 'file' : 'systemd-report@.service.in' }, { 'file' : 'systemd-report-basic.socket' }, { 'file' : 'systemd-report-basic@.service.in' }, { diff --git a/units/systemd-report.socket b/units/systemd-report.socket new file mode 100644 index 0000000000..9a569a7834 --- /dev/null +++ b/units/systemd-report.socket @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +[Unit] +Description=System Report Socket +Documentation=man:systemd-report(1) +DefaultDependencies=no +Before=sockets.target + +[Socket] +ListenStream=/run/systemd/io.systemd.Report +FileDescriptorName=varlink +SocketMode=0600 +Accept=yes +MaxConnectionsPerSource=16 +RemoveOnStop=yes + +[Install] +WantedBy=sockets.target diff --git a/units/systemd-report@.service.in b/units/systemd-report@.service.in new file mode 100644 index 0000000000..41c4c5a700 --- /dev/null +++ b/units/systemd-report@.service.in @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. +[Unit] +Description=System Report +Documentation=man:systemd-report(1) + +DefaultDependencies=no +Conflicts=shutdown.target +Before=shutdown.target + +[Service] +ExecStart={{LIBEXECDIR}}/systemd-report From 9ebbbfd00e5da549f72813f1436c280180a21bf8 Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 10:39:40 +0200 Subject: [PATCH 8/9] ci: add test for the new logic --- test/units/TEST-74-AUX-UTILS.report.sh | 109 ++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/test/units/TEST-74-AUX-UTILS.report.sh b/test/units/TEST-74-AUX-UTILS.report.sh index 48bbc4841a..f758d24030 100755 --- a/test/units/TEST-74-AUX-UTILS.report.sh +++ b/test/units/TEST-74-AUX-UTILS.report.sh @@ -109,7 +109,9 @@ CERTDIR=$(mktemp -d) at_exit() { set +e systemctl stop fake-report-server fake-report-server-tls - rm -rf "$CERTDIR" + systemctl stop systemd-report.socket systemd-report-files.socket systemd-report-sign-plain.socket + rm -f /run/systemd/report.files/testreportfile /run/systemd/report.files/binaryfile + rm -rf "$CERTDIR" "${SIGN_WORK:-}" } trap at_exit EXIT @@ -132,3 +134,108 @@ systemctl status fake-report-server-tls "$REPORT" upload --url=https://localhost:8090/ --key=- --trust="$CERTDIR/server.crt" \ --extra-header='Authorization: Bearer magic string' + +# ----------------------------------------------------------------------------- +# Test the systemd-report-files@.service metric provider: files dropped into one +# of the report.files directories are exposed verbatim as io.systemd.Files.* +# metrics, keyed by their file name. +mkdir -p /run/systemd/report.files +REPORT_FILE_CONTENT="hello from the report files metric provider" +printf '%s' "$REPORT_FILE_CONTENT" >/run/systemd/report.files/testreportfile + +systemctl start systemd-report-files.socket +varlinkctl info /run/systemd/report/io.systemd.Files +varlinkctl list-methods /run/systemd/report/io.systemd.Files + +# The metric value must be the exact file contents. +files_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.Files io.systemd.Metrics.List {})" +files_value() { echo "$files_metrics" | jq --seq -r "select(.name == \"$1\") | .value"; } +[ "$(files_value io.systemd.Files.testreportfile)" = "$REPORT_FILE_CONTENT" ] + +# Describe must list the very same metric, of type string. +files_describe="$(varlinkctl call --more /run/systemd/report/io.systemd.Files io.systemd.Metrics.Describe {})" +echo "$files_describe" | jq --seq -r 'select(.name == "io.systemd.Files.testreportfile") | .type' | grep -wx string >/dev/null + +# Files that aren't valid UTF-8 text must be skipped silently (the value is a +# JSON string, so only text files can be reported). +printf '\xff\xfe binary garbage' >/run/systemd/report.files/binaryfile +files_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.Files io.systemd.Metrics.List {})" +[ -z "$(files_value io.systemd.Files.binaryfile)" ] +rm -f /run/systemd/report.files/binaryfile + +# ----------------------------------------------------------------------------- +# Test report signing through the plain software backend, driven entirely via +# the io.systemd.Report Varlink interface (the GenerateSigned method). The plain +# backend is only built/installed with OpenSSL support, so skip if unavailable. +if ! systemctl cat systemd-report-sign-plain.socket &>/dev/null; then + echo "systemd-report-sign-plain.socket is not installed, skipping report signing test." + exit 0 +fi + +systemctl start systemd-report.socket +systemctl start systemd-report-sign-plain.socket + +SIGN_WORK="$(mktemp -d)" + +# Ask systemd-report to generate a *signed* report over Varlink. The reply +# carries the signed report as base64-encoded JSON-SEQ data. +varlinkctl call /run/systemd/io.systemd.Report io.systemd.Report.GenerateSigned \ + '{"matches":["io.systemd.Manager.UnitsTotal"]}' | jq -r .reportData | base64 -d >"$SIGN_WORK/report.seq" + +# The first JSON-SEQ record is the report itself. This is exactly the byte +# sequence that got signed, including the leading record separator (0x1e) and +# the trailing newline, so 'head -n1' reproduces it verbatim. +head -n1 "$SIGN_WORK/report.seq" >"$SIGN_WORK/message.bin" +tr -d '\036' <"$SIGN_WORK/message.bin" | jq -e '.mediaType == "application/vnd.io.systemd.report"' >/dev/null + +# The remaining record(s) are signature objects. With only the plain backend +# enabled there is exactly one, with mechanism "plain". +sig_json="$(tail -n +2 "$SIGN_WORK/report.seq" | tr -d '\036')" +[ "$(echo "$sig_json" | jq -r .mechanism)" = "plain" ] +[ "$(echo "$sig_json" | jq -r .mediaType)" = "application/vnd.io.systemd.report.signature" ] + +# The sha256 recorded in the signature must match the digest of the report bytes. +[ "$(echo "$sig_json" | jq -r .sha256)" = "$(sha256sum "$SIGN_WORK/message.bin" | cut -d' ' -f1)" ] + +# Extract the embedded public key and the raw Ed25519 signature. +echo "$sig_json" | jq -r .data.key >"$SIGN_WORK/pub.pem" +echo "$sig_json" | jq -r .data.signature | base64 -d >"$SIGN_WORK/sig.bin" + +# The signature is made over the SHA256 digest bytes directly (Ed25519, the +# signer does not pre-hash), so verify the raw digest against the signature. +openssl dgst -sha256 -binary "$SIGN_WORK/message.bin" >"$SIGN_WORK/digest.bin" +openssl pkeyutl -verify -pubin -inkey "$SIGN_WORK/pub.pem" -rawin \ + -in "$SIGN_WORK/digest.bin" -sigfile "$SIGN_WORK/sig.bin" + +# The public key embedded in the report must be the very key the plain backend +# generated and persisted on disk. +diff <(openssl pkey -pubin -in "$SIGN_WORK/pub.pem" -pubout) \ + <(openssl pkey -pubin -in /var/lib/systemd/report.sign.plain/local.public -pubout) + +# A tampered report must no longer verify against the signature. +printf 'x' >>"$SIGN_WORK/message.bin" +openssl dgst -sha256 -binary "$SIGN_WORK/message.bin" >"$SIGN_WORK/digest-bad.bin" +if openssl pkeyutl -verify -pubin -inkey "$SIGN_WORK/pub.pem" -rawin \ + -in "$SIGN_WORK/digest-bad.bin" -sigfile "$SIGN_WORK/sig.bin"; then + echo "Signature verification unexpectedly succeeded for tampered report" >&2 + exit 1 +fi + +# ----------------------------------------------------------------------------- +# The plain backend also acts as a metric provider, exposing its public signing +# key as the io.systemd.Report.SignPlain.PublicKey metric. This is served off the +# very same socket as the signer, reachable in the metrics directory under +# /run/systemd/report/ (the signer endpoint is just a symlink into the root-only +# /run/systemd/report.sign/ directory pointing at the same socket). +varlinkctl info /run/systemd/report/io.systemd.Report.SignPlain +varlinkctl list-methods /run/systemd/report/io.systemd.Report.SignPlain + +# The exposed public key must be the very key persisted on disk. +sign_metrics="$(varlinkctl call --more /run/systemd/report/io.systemd.Report.SignPlain io.systemd.Metrics.List {})" +echo "$sign_metrics" | jq --seq -r 'select(.name == "io.systemd.Report.SignPlain.PublicKey") | .value' >"$SIGN_WORK/metric-pub.pem" +diff <(openssl pkey -pubin -in "$SIGN_WORK/metric-pub.pem" -pubout) \ + <(openssl pkey -pubin -in /var/lib/systemd/report.sign.plain/local.public -pubout) + +# Describe must list the very same metric, of type string. +varlinkctl call --more /run/systemd/report/io.systemd.Report.SignPlain io.systemd.Metrics.Describe {} | + jq --seq -r 'select(.name == "io.systemd.Report.SignPlain.PublicKey") | .type' | grep -wx string >/dev/null From 33323262ec66844beab44fbe892825b9d998db4e Mon Sep 17 00:00:00 2001 From: Lennart Poettering Date: Mon, 15 Jun 2026 08:59:44 +0200 Subject: [PATCH 9/9] update TODO --- TODO.md | 92 +++++++++++++++++---------------------------------------- 1 file changed, 27 insertions(+), 65 deletions(-) diff --git a/TODO.md b/TODO.md index ccb924acd2..354af92755 100644 --- a/TODO.md +++ b/TODO.md @@ -128,17 +128,36 @@ SPDX-License-Identifier: LGPL-2.1-or-later ## Features +- **report:** + - implement signer for TPM2 that adds a quote + event log excerpt as signing + object. should include a TPM timestamp, and some "generation ID" provided + by an orchestrator to guarantee freshness. + - implement metrics provider in logind: report number of active + sessions, and number of sessions since boot. + - implement metrics provider in journald that reports number of log messages + received since boot, by log priority + - implement metrics provider in journalctl, that simply reports the most + recent 10 emergency log msgs on the system + - allow to compile statically (together with the basic and cgroup + backends) + - make sure backends can also be invoked via forking off + - allow metrics providers to indicate which reported values mean + "nothing"/"invalid"/"zero"/"please-suppress". Then use that to reduce noise + in systemd-report output. + - teach cgroup metrics provider to expose PSI information + - implement metrics provider that reports local IP addresses, and bound open + IP ports + - implement current system load via metrics + - metrics from pid1: suppress metrics form units that are inactive and have nothing to report + - pass filtering hints to services, so that they can also be applied server-side, not just client side + - add "hint-suppress-zero" flag (which suppresses all metrics which are zero) + - add "hint-object" parameter (which only queries info about certain object) + - make systemd-report a varlink service + - bootctl set-tries for setting retry counters on boot entries -- report: allow to compile statically (together with the basic and cgroup - backends) - -- report: make sure backends can also be invoked via forking off - -- report: backend that extracts 10 most recent log msgs of a certain priority - - implement enough of PCP in a new sd-pcp-client library that networkd can use - to punch holes for wireguard into common NAT routers. + to punch holes for wireguard into common NAT routers. use that in networkd. alternatively: just use libjuice - measure an uapi16 manifest of /etc/ during early boot (so that pre-initialized /etc/ can be detected when systems are enrolled into some @@ -223,8 +242,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later traffic of all sockets marked via the new varlink socket xattrs. Use BPF for all of that of course. -- systemd-report: implement signing via callout varlink dir - - add tooling for generating dictionary-based hostnames - do not pull dbus daemon/broker anymore, instead lazy activate it. Given how @@ -237,12 +254,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later hence we might as well associate the title with the table itself, and streamline a few things. -- allow metrics to indicate which values mean - "nothing"/"invalid"/"zero"/"please-suppress". Then use that to reduce noise - in systemd-report output. - -- cgroup-metrics: add per-cgroup PSI metrics - - sysupdate: offer reading transfer files/components/features optionally from some JSON fragment rather than transfer files, so that we can update it independently from any DDI, and it needs no activation cycle. Why? so that @@ -319,16 +330,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later retriggers the fs is was invoked for, which causes the udev rules to rerun that assemble the btrfs raid, but this time force degraded assembly. -- add a report backend that simply exposes a bunch of static files that are - symlinked to some dir {/run,/etc/,/var/lib/}systemd/report-files/ or so as - facts. Use that for exposing SSH keys and suchlike. - -- report generators for: - - ip addresses - - imds address - - tpm event log - - open IP ports - - a way for container managers to turn off getty starting via $container_headless= or so... - add "conditions" for bls type 1 and type 2 profiles that allow suppressing @@ -889,15 +890,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - ddi must be listed as block device fstype -- define a generic "report" varlink interface, which services can implement to - provide health/statistics data about themselves. then define a dir somewhere - in /run/ where components can bind such sockets. Then make journald, logind, - and pid1 itself implement this and expose various stats on things there. Then - issue parallel calls to these interfaces from the systemd-report tool, - combine into one json document, and include measurement logs and tpm - quote. tpm quote should protect the json doc via the nonce field - studd. Allow shipping this off elsewhere for analyze. - - define a JSON format for units, separating out unit definitions from unit runtime state. Then, expose it: @@ -1667,28 +1659,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - man: the documentation of Restart= currently is very misleading and suggests the tools from ExecStartPre= might get restarted. -- maybe add a "systemd-report" tool, that generates a TPM2-backed "report" of - current system state, i.e. a combination of PCR information, local system - time and TPM clock, running services, recent high-priority log - messages/coredumps, system load/PSI, signed by the local TPM chip, to form an - enhanced remote attestation quote. Use case: a simple orchestrator could use - this: have the report tool upload these reports every 3min somewhere. Then - have the orchestrator collect these reports centrally over a 3min time - window, and use them to determine what which node should now start/stop what, - and generate a small confext for each node, that uses Uphold= to pin services - on each node. The confext would be encrypted using the asymmetric encryption - proposed above, so that it can only be activated on the specific host, if the - software is in a good state, and within a specific time frame. Then run a - loop on each node that sends report to orchestrator and then sysupdate to - update confext. Orchestrator would be stateless, i.e. operate on desired - config and collected reports in the last 3min time window only, and thus can - be trivially scaled up since all instances of the orchestrator should come to - the same conclusions given the same inputs of reports/desired workload info. - Could also be used to deliver Wireguard secrets and thus to clients, thus - permitting zero-trust networking: secrets are rolled over via confext updates, - and via the time window TPM logic invalidated if node doesn't keep itself - updated, or becomes corrupted in some way. - - maybe add a new standard slice where process that are started in the initrd and stick around for the whole system runtime (i.e. root fs storage daemons, the bpf loader daemon discussed above, and such) are placed. maybe @@ -2241,14 +2211,6 @@ SPDX-License-Identifier: LGPL-2.1-or-later - report: have something that requests cloud workload identity bearer tokens and includes it in the report -- **report:** - - plug "facts" into systemd-report too, i.e. stuff that is more static, such as hostnames, ssh keys and so on. - - pass filtering hints to services, so that they can also be applied server-side, not just client side - - metrics from pid1: suppress metrics form units that are inactive and have nothing to report - - add "hint-suppress-zero" flag (which suppresses all metrics which are zero) - - add "hint-object" parameter (which only queries info about certain object) - - make systemd-report a varlink service - - Reset TPM2 DA bit on each successful boot - **resolved:**