journal-remote: optionally record client certificate subjects

Add AddClientCertificateSubject= to record verified HTTPS client subjects
in _REMOTE_CERTIFICATE_SUBJECT, allowing attribution to the immediate
uploader even when multiple clients share an output journal.

Default to disabled to preserve existing behavior. When enabled, replace
incoming copies of the field and omit it for unauthenticated inputs.

Signed-off-by: Everton de Matos <71781163+everton-dematos@users.noreply.github.com>
This commit is contained in:
Everton de Matos authored and Zbigniew Jędrzejewski-Szmek committed 2026-10-19 22:18:38 +02:00
1 parent 55326a3873
commit f3f06acba7
9 files changed
+141 -1

No files matched your search

+12
View File
@@ -100,6 +100,18 @@
<xi:include href="version-info.xml" xpointer="v220"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>AddClientCertificateSubject=</varname></term>
<listitem><para>Takes a boolean. If enabled, records the verified HTTPS client's full certificate
subject in <varname>_REMOTE_CERTIFICATE_SUBJECT=</varname>. All incoming copies of this field are
removed, including from non-HTTPS inputs and imported files. Without a verified client certificate,
the field is omitted. Certificate verification and connection acceptance are unchanged.
Defaults to no, leaving incoming fields unchanged.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>MaxUse=</varname></term>
<term><varname>KeepFree=</varname></term>
+10
View File
@@ -456,6 +456,16 @@
<xi:include href="version-info.xml" xpointer="v252"/></listitem>
</varlistentry>
<varlistentry>
<term><varname>_REMOTE_CERTIFICATE_SUBJECT=</varname></term>
<listitem><para>The full distinguished name of the verified HTTPS uploader's certificate.
Added by <command>systemd-journal-remote</command> with <varname>AddClientCertificateSubject=yes</varname>;
see <citerefentry><refentrytitle>journal-remote.conf</refentrytitle><manvolnum>5</manvolnum></citerefentry>.
Trust this field only from a receiver with this setting enabled. It identifies the immediate uploader,
not the original producer of forwarded entries, and does not authenticate other entry fields.</para>
<xi:include href="version-info.xml" xpointer="v263"/></listitem>
</varlistentry>
</variablelist>
</refsect1>
+7 -1
View File
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "sd-journal.h"
@@ -23,7 +24,9 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
_cleanup_(rm_rf_physical_and_freep) char *tmp = NULL;
_cleanup_(unlink_and_freep) char *name = NULL;
_cleanup_(sd_journal_closep) sd_journal *j = NULL;
_cleanup_(journal_remote_server_destroy) RemoteServer s = {};
_cleanup_(journal_remote_server_destroy) RemoteServer s = {
.add_client_certificate_subject = size % 3 != 0,
};
int fdin, r;
if (outside_size_range(size, 3, 65536))
@@ -53,6 +56,9 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
ASSERT_OK_POSITIVE(journal_remote_add_source(&s, fdin, (char*) "fuzz-data", false));
TAKE_FD(fdin_close);
if (size % 3 == 2)
assert_se(s.sources[fdin]->client_certificate_subject = strdup(REMOTE_CERTIFICATE_SUBJECT_FIELD "CN=fuzz"));
while (s.active)
ASSERT_OK(journal_remote_handle_raw_source(NULL, fdin, 0, &s));
+13
View File
@@ -48,6 +48,7 @@ static char *arg_listen_https = NULL;
static char **arg_files = NULL;
static bool arg_compress = true;
static bool arg_seal = false;
static bool arg_add_client_certificate_subject = false;
static int http_socket = -1, https_socket = -1;
static char **arg_gnutls_log = NULL;
@@ -250,6 +251,12 @@ static int request_meta(void **connection_cls, int fd, char *_hostname) {
if (!source)
return log_oom();
if (journal_remote_server_global->add_client_certificate_subject && journal_remote_server_global->check_trust) {
source->client_certificate_subject = strjoin(REMOTE_CERTIFICATE_SUBJECT_FIELD, source->importer.name);
if (!source->client_certificate_subject)
return log_oom();
}
log_debug("Added RemoteSource as connection metadata %p", source);
r = build_accept_encoding(&source->encoding);
@@ -849,6 +856,7 @@ static int parse_config(void) {
{ "Remote", "MaxFiles", config_parse_uint64, 0, &arg_n_max_files },
{ "Remote", "KeepFree", config_parse_iec_uint64, 0, &arg_keep_free },
{ "Remote", "Compression", config_parse_compression, 0, &arg_compression },
{ "Remote", "AddClientCertificateSubject", config_parse_bool, 0, &arg_add_client_certificate_subject },
{}
};
@@ -1184,6 +1192,11 @@ static int run(int argc, char **argv) {
s.check_trust = !arg_trust_all;
}
s.add_client_certificate_subject = arg_add_client_certificate_subject;
if (s.add_client_certificate_subject && !s.check_trust)
log_notice("Client certificate verification is unavailable or disabled; "
"removing incoming certificate subjects without adding a verified subject.");
journal_reset_metrics(&s.metrics);
s.metrics.max_use = arg_max_use;
s.metrics.max_size = arg_max_size;
+34
View File
@@ -1,10 +1,14 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include <string.h>
#include "sd-event.h"
#include "alloc-util.h"
#include "journal-remote-parse.h"
#include "journal-remote.h"
#include "log.h"
#include "string-util.h"
RemoteSource* source_free(RemoteSource *source) {
if (!source)
@@ -19,6 +23,7 @@ RemoteSource* source_free(RemoteSource *source) {
sd_event_source_unref(source->buffer_event);
free(source->encoding);
free(source->client_certificate_subject);
return mfree(source);
}
@@ -71,6 +76,35 @@ int process_source(RemoteSource *source, JournalFileFlags file_flags) {
assert(source->importer.iovw.iovec);
if (source->writer->server->add_client_certificate_subject) {
struct iovec_wrapper *iovw = &source->importer.iovw;
size_t n = 0;
/* Drop all supplied copies, including binary fields and metadata from earlier hops. */
FOREACH_ARRAY(i, iovw->iovec, iovw->count) {
if (memory_startswith(i->iov_base, i->iov_len, REMOTE_CERTIFICATE_SUBJECT_FIELD))
continue;
iovw->iovec[n++] = *i;
}
iovw->count = n;
if (source->client_certificate_subject) {
r = iovw_put(iovw, source->client_certificate_subject, strlen(source->client_certificate_subject));
if (r == -E2BIG) {
log_warning("Entry has no room for the client certificate subject, skipping.");
r = 1;
goto freeing;
}
if (r < 0)
goto freeing;
}
if (iovw_isempty(iovw)) {
log_warning("Entry with no payload after removing certificate subjects, skipping.");
goto freeing;
}
}
r = writer_write(source->writer,
&source->importer.iovw,
&source->importer.ts,
@@ -15,6 +15,7 @@ typedef struct RemoteSource {
sd_event_source *buffer_event;
Compression compression;
char *encoding;
char *client_certificate_subject;
} RemoteSource;
RemoteSource* source_new(int fd, bool passive_fd, char *name, Writer *writer);
@@ -22,6 +22,7 @@
# ServerKeyFile={{CERTIFICATE_ROOT}}/private/journal-remote.pem
# ServerCertificateFile={{CERTIFICATE_ROOT}}/certs/journal-remote.pem
# TrustedCertificateFile={{CERTIFICATE_ROOT}}/ca/trusted.pem
# AddClientCertificateSubject=false
# MaxUse=
# KeepFree=
# MaxFileSize=
+3
View File
@@ -5,6 +5,8 @@
#include "journal-remote-parse.h"
#include "journal-remote-write.h"
#define REMOTE_CERTIFICATE_SUBJECT_FIELD "_REMOTE_CERTIFICATE_SUBJECT="
typedef struct RemoteServer {
RemoteSource **sources;
size_t active;
@@ -22,6 +24,7 @@ typedef struct RemoteServer {
JournalWriteSplitMode split_mode;
JournalFileFlags file_flags;
bool check_trust;
bool add_client_certificate_subject;
JournalMetrics metrics;
} RemoteServer;
extern RemoteServer *journal_remote_server_global;
@@ -309,6 +309,66 @@ systemctl stop systemd-journal-upload
systemctl stop systemd-journal-remote.{socket,service}
rm -rf /var/log/journal/remote/*
rm /run/systemd/journal-upload.conf.d/99-test.conf
# Reuse the trusted HTTPS receiver to check attribution and replacement of supplied fields.
max_fields="$(getconf IOV_MAX)"
for setting in '' yes; do
if [[ -n "$setting" ]]; then
echo "AddClientCertificateSubject=$setting" >>/run/systemd/journal-remote.conf.d/99-test.conf
fi
{
printf '%s\n' '__REALTIME_TIMESTAMP=1700000000000000' '__MONOTONIC_TIMESTAMP=1000000' \
'_BOOT_ID=0123456789abcdef0123456789abcdef' 'MESSAGE=field-limit-test'
for ((i = 2; i < max_fields; i++)); do
printf 'FIELD_%s=value\n' "$i"
done
printf '\n'
printf '%s\n\n' '_REMOTE_CERTIFICATE_SUBJECT=CN=forged-only'
printf '%s\n' '__REALTIME_TIMESTAMP=1700000000000000' '__MONOTONIC_TIMESTAMP=1000000' \
'_BOOT_ID=0123456789abcdef0123456789abcdef' \
'_REMOTE_CERTIFICATE_SUBJECT=CN=forged-one' 'MESSAGE=certificate-subject-test' \
'_REMOTE_CERTIFICATE_SUBJECT=CN=forged-two' '_HOSTNAME=sender-claimed-host'
printf '_REMOTE_CERTIFICATE_SUBJECT\n\x03\0\0\0\0\0\0\0a\0b\n\n'
} >/run/systemd/remote-pki/subject.export
systemctl restart systemd-journal-remote.socket
for _ in 1 2; do
curl --silent --show-error --fail --max-time 15 \
--cacert /run/systemd/remote-pki/ca.crt \
--cert /run/systemd/remote-pki/client.crt \
--key /run/systemd/remote-pki/client.key \
--header 'Content-Type: application/vnd.fdo.journal' \
--data-binary @/run/systemd/remote-pki/subject.export https://localhost:19532/upload
done
expected_entries=6
expected='["CN=forged-one","CN=forged-two",[97,0,98]]'
if [[ "$setting" == yes ]]; then
expected_entries=4
expected=null
if /usr/lib/systemd/systemd-journal-remote --version | grep -F +GNUTLS >/dev/null; then
expected='"C=CZ,L=Brno,O=Foo,OU=Bar,CN=Test Client"'
fi
fi
journalctl --directory=/var/log/journal/remote -o json --all |
jq -se --argjson expected "$expected" --argjson count "$expected_entries" '
length == $count and ([.[] | select(.MESSAGE == "certificate-subject-test")] |
length == 2 and all(.[];
._REMOTE_CERTIFICATE_SUBJECT == $expected and ._HOSTNAME == "sender-claimed-host"))'
systemctl stop systemd-journal-remote.{socket,service}
rm -rf /var/log/journal/remote/*
/usr/lib/systemd/systemd-journal-remote --split-mode=none \
--output=/run/systemd/remote-pki/import.journal /run/systemd/remote-pki/subject.export
expected_entries=3
if [[ "$setting" == yes ]]; then
expected_entries=2
expected=null
fi
journalctl --file='/run/systemd/remote-pki/import*.journal' -o json --all |
jq -se --argjson expected "$expected" --argjson count "$expected_entries" 'length == $count and
([.[] | select(.MESSAGE == "certificate-subject-test")] | length == 1 and
.[0]._REMOTE_CERTIFICATE_SUBJECT == $expected and .[0]._HOSTNAME == "sender-claimed-host")'
rm /run/systemd/remote-pki/import*.journal
done
rm /run/systemd/journal-remote.conf.d/99-test.conf
DOS_DIR="$(mktemp -d)"