From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id DF9611FF12C for ; Wed, 05 Aug 2026 11:45:35 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 6A7E3215AB; Wed, 05 Aug 2026 11:45:21 +0200 (CEST) From: Maximiliano Sandoval To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-mini-journalreader v2 1/2] add -B parameter to lists boots Date: Wed, 5 Aug 2026 11:45:12 +0200 Message-ID: <20260805094516.220516-2-m.sandoval@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260805094516.220516-1-m.sandoval@proxmox.com> References: <20260805094516.220516-1-m.sandoval@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1785923103034 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.537 Adjusted score from AWL reputation of From: address BITCOIN_OBFU_SUBJ 1 Bitcoin + obfuscated subject DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) PDS_BTC_ID 0.498 FP reduced Bitcoin ID RCVD_IN_DNSWL_LOW -0.7 Sender listed at https://www.dnswl.org/, low trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: VC35KYARRAOVMJ7NJ2HKSYXHZIWMJZOI X-Message-ID-Hash: VC35KYARRAOVMJ7NJ2HKSYXHZIWMJZOI X-MailFrom: m.sandoval@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: The parameter will print all boots, in practice it should be piped into tail. Sample output: 2026-07-27 08:28 2026-07-27 15:56 3e3813556d53477f9a139a7cb2f3e8f2 7.0.14-6-pve 2026-07-27 15:57 2026-07-27 17:03 5a0324e1ac3f4f24907a21ab81b67384 7.0.14-7-pve 2026-07-28 08:26 2026-07-28 17:07 e62bc71e85274f21a1bfb954be38c071 7.0.14-7-pve 2026-07-29 08:43 2026-07-29 08:48 7f668e4499644ea1b8339d6b8c29d53d 7.0.14-7-pve 2026-07-29 08:49 2026-07-29 17:09 9ff5eb6f0485466383e0b89512f937c9 7.0.14-8-pve glib is added as a dependency for simplicity of memory management. As per SD_JOURNAL_QUERY_UNIQUE(3), sd_journal_query_unique ignore matches hence why we need to iterate twice: Note that these functions currently are not influenced by matches set with sd_journal_add_match() but this might change in a later version of this software. Signed-off-by: Maximiliano Sandoval --- Notes: We usually require the output from `last` or similar in support to query the previously booted kernel versions. We add this helper to avoid adding a dependency on either last or wtmpdb on the ISO images. This seems quick enough for ~400 boots: time sudo ./proxmox-mini-journalreader -B | wc -l 417 real 0m0,143s user 0m0,004s sys 0m0,004s Open questions: - Should it list the latest boots first? It amounts to reversing the sign on boot_info_cmp. - We assume the first line in the journal is the "Linux version" line. This is true as of now in practice. One could add a match like: sd_journal_add_match(j, "_TRANSPORT=kernel", strlen("_TRANSPORT=kernel")); if this is undesirable. Differences from v1: - Replace uint with size_t - Remove the use of a second journal - Copy only 32 chars instead of 33. We were copying \0 which is added by g_strndup anyways and 32 is the actual length of a BOOT_ID - use strlen("BOOT_ID=") instead of sizeof("BOOT_ID") so it does not work by accident - Use proper signature for GCompareFunc - Add a couple of guards for empty messages debian/control | 2 +- src/Makefile | 2 +- src/mini-journalreader.c | 154 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 3 deletions(-) diff --git a/debian/control b/debian/control index 8280f6d..d7d8cc0 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: proxmox-mini-journalreader Section: admin Priority: optional Maintainer: Proxmox Support Team -Build-Depends: debhelper-compat (= 13), libsystemd-dev, pkg-config, scdoc, +Build-Depends: debhelper-compat (= 13), libsystemd-dev, pkg-config, scdoc, libglib2.0-dev, Standards-Version: 4.6.2 Package: proxmox-mini-journalreader diff --git a/src/Makefile b/src/Makefile index e64e066..c89e74a 100644 --- a/src/Makefile +++ b/src/Makefile @@ -5,7 +5,7 @@ LIBEXEC_DIR ?= $(DESTDIR)/usr/libexec/ MAN1_DIR ?= $(DESTDIR)/usr/share/man/man1 MANPAGE ?= $(PROGRAM).1 -LIBS := libsystemd +LIBS := libsystemd glib-2.0 CFLAGS += -Wall -Wextra -Wl,-z,relro -g -O2 --std=gnu11 CFLAGS += -fstack-protector-strong -D_FORTIFY_SOURCE=2 CFLAGS += $(shell pkg-config --cflags $(LIBS)) diff --git a/src/mini-journalreader.c b/src/mini-journalreader.c index fc78f65..7d0d76b 100644 --- a/src/mini-journalreader.c +++ b/src/mini-journalreader.c @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -37,6 +38,148 @@ bool json = false; bool structured = false; bool first_line = true; +typedef struct { + char *boot_id; + char *version; + uint64_t first_timestamp; + uint64_t last_timestamp; +} BootInfo; + +static void boot_info_free(BootInfo *info) { + g_free(info->boot_id); + g_free(info->version); + g_free(info); +} + +static int boot_info_cmp(gconstpointer a, gconstpointer b) { + const BootInfo *entry1 = a; + const BootInfo *entry2 = b; + + if (entry1->first_timestamp < entry2->first_timestamp) return -1; + if (entry1->first_timestamp > entry2->first_timestamp) return 1; + return 0; +} + +char* get_version_from_journal_entry(const char *message) { + g_autoptr(GRegex) regex = NULL; + g_autoptr(GMatchInfo) match_info = NULL; + g_autoptr(GError) error = NULL; + int start_pos = 0, end_pos = 0; + + g_return_val_if_fail(message != NULL, g_strdup("unknown")); + + regex = g_regex_new("Linux version ([a-zA-Z0-9.-]+)", G_REGEX_OPTIMIZE, G_REGEX_MATCH_DEFAULT, &error); + if (error) { + g_critical("Regex compilation failed: %s", error->message); + return g_strdup("unknown"); + } + + g_return_val_if_fail(regex != NULL, g_strdup("unknown")); + + g_regex_match(regex, message, G_REGEX_MATCH_DEFAULT, &match_info); + + if (!g_match_info_matches(match_info)) + return g_strdup("unknown"); + + g_match_info_fetch_pos(match_info, 1, &start_pos, &end_pos); + + return g_strndup(message + start_pos, end_pos - start_pos); +} + +char* format_timestamp(uint64_t timestamp) { + g_autoptr(GDateTime) time = NULL; + + if (timestamp == 0) { + return g_strdup ("-"); + } + + time = g_date_time_new_from_unix_local_usec(timestamp); + return g_date_time_format(time, "%Y-%m-%d %H:%M"); +} + +static void print_boot(BootInfo *info, gpointer user_data) { + g_return_if_fail(info != NULL); + + g_autofree char *start = format_timestamp(info->first_timestamp); + g_autofree char *end = format_timestamp(info->last_timestamp); + g_print("%s\t%s\t%s\t%s\n", start, end, info->boot_id, info->version); +} + +static int list_boots_and_kernels(sd_journal *j) { + const void *data; + const size_t prefix_len = strlen("_BOOT_ID="); + size_t len; + int r; + g_autoptr(GPtrArray) boot_ids = NULL; + g_autoptr(GPtrArray) boot_array = NULL; + + boot_ids = g_ptr_array_new_with_free_func(g_free); + + r = sd_journal_query_unique(j, "_BOOT_ID"); + if (r < 0) { + g_printerr("Failed to query _BOOT_ID identifier: %s\n", strerror(-r)); + return 1; + } + SD_JOURNAL_FOREACH_UNIQUE(j, data, len) { + g_ptr_array_add(boot_ids, g_strndup((const char *)data, len)); + } + + boot_array = g_ptr_array_new_full(boot_ids->len, (GDestroyNotify)boot_info_free); + + sd_journal_flush_matches(j); + + for (size_t i = 0; i < boot_ids->len; i++) { + uint64_t first_ts = 0, last_ts = 0; + size_t msg_len; + const char *message = NULL; + const char *match = g_ptr_array_index(boot_ids, i); + g_autofree char* version = NULL; + g_autofree char *boot_id = NULL; + + // Strip _BOOT_ID= from the message + boot_id = g_strndup(match + prefix_len, 32); + + sd_journal_add_match(j, match, strlen(match)); + + r = sd_journal_seek_head(j); + if (r >= 0) { + r = sd_journal_next(j); + if (r > 0) { + sd_journal_get_realtime_usec(j, &first_ts); + } + } + + r = sd_journal_get_data(j, "MESSAGE", (const void **)&message, &msg_len); + if (r >= 0 && message != NULL && msg_len > 0) + version = get_version_from_journal_entry(message); + else + version = g_strdup("unknown"); + + r = sd_journal_seek_tail(j); + if (r >= 0) { + r = sd_journal_previous(j); + if (r > 0) { + sd_journal_get_realtime_usec(j, &last_ts); + } + } + + sd_journal_flush_matches(j); + + BootInfo *boot_info = g_new0 (BootInfo, 1); + boot_info->boot_id = g_steal_pointer(&boot_id); + boot_info->version = g_steal_pointer(&version); + boot_info->first_timestamp = first_ts; + boot_info->last_timestamp = last_ts; + + g_ptr_array_add(boot_array, boot_info); + } + + g_ptr_array_sort_values(boot_array, (GCompareFunc)boot_info_cmp); + g_ptr_array_foreach(boot_array, (GFunc)print_boot, NULL); + + return 0; +} + // helper to print errors on stderr // if we're in json mode, print closing json body if possible static void print_error_and_exit(const char *fmt, ...) { @@ -471,6 +614,7 @@ _Noreturn static void usage(char *error) { " -J\t\t\tprint as json with one object of separate fields per entry\n" " -I\t\t\twith -J, also emit a record listing the distinct syslog identifiers\n" " -U\t\t\twith -J, also emit a record listing the distinct systemd units\n" + " -B\t\t\tList boots. The columns are: the date of the first entry in the boot and last entry, the boot id, and the kernel version\n" " -h\t\t\tthis help\n" "\n" "Passing no range option will dump all the available journal\n" @@ -711,11 +855,12 @@ int main(int argc, char *argv[]) { bool kernel = false; bool list_identifiers = false; bool list_units = false; + bool list_boots = false; int c; progname = argv[0]; - while ((c = getopt(argc, argv, "b:e:d:n:f:t:p:i:u:jJIUkh")) != -1) { + while ((c = getopt(argc, argv, "b:e:d:n:f:t:p:i:u:jJIUkh:B")) != -1) { switch (c) { case 'b': begin = arg_to_timestamp_usec(optarg); @@ -762,6 +907,9 @@ int main(int argc, char *argv[]) { case 'U': list_units = true; break; + case 'B': + list_boots = true; + break; case 'h': usage(NULL); case '?': @@ -816,6 +964,10 @@ int main(int argc, char *argv[]) { return 1; } + if (list_boots) { + return list_boots_and_kernels(j); + } + // restrict the traversal before seeking. Each filter is a separate group, conjoined (AND) with // the others; within a group matches are OR'd (same field, or an explicit disjunction for the // unit), so the groups must be split by a conjunction -- 2.47.3