diff --git a/man/journal-remote.conf.xml b/man/journal-remote.conf.xml
index a9d92c33720..709c9ba198d 100644
--- a/man/journal-remote.conf.xml
+++ b/man/journal-remote.conf.xml
@@ -100,6 +100,18 @@
+
+ AddClientCertificateSubject=
+
+ Takes a boolean. If enabled, records the verified HTTPS client's full certificate
+ subject in _REMOTE_CERTIFICATE_SUBJECT=. 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.
+
+
+
+
MaxUse=
KeepFree=
diff --git a/man/systemd.journal-fields.xml b/man/systemd.journal-fields.xml
index 9f7f2c5cfa0..6c72d22126f 100644
--- a/man/systemd.journal-fields.xml
+++ b/man/systemd.journal-fields.xml
@@ -456,6 +456,16 @@
+
+ _REMOTE_CERTIFICATE_SUBJECT=
+ The full distinguished name of the verified HTTPS uploader's certificate.
+ Added by systemd-journal-remote with AddClientCertificateSubject=yes;
+ see journal-remote.conf5.
+ 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.
+
+
+
diff --git a/src/journal-remote/fuzz-journal-remote.c b/src/journal-remote/fuzz-journal-remote.c
index 9119142e82e..25dcdf8fa75 100644
--- a/src/journal-remote/fuzz-journal-remote.c
+++ b/src/journal-remote/fuzz-journal-remote.c
@@ -1,6 +1,7 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
#include
+#include
#include
#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));
diff --git a/src/journal-remote/journal-remote-main.c b/src/journal-remote/journal-remote-main.c
index e3d03dd496d..3f12db6a491 100644
--- a/src/journal-remote/journal-remote-main.c
+++ b/src/journal-remote/journal-remote-main.c
@@ -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;
diff --git a/src/journal-remote/journal-remote-parse.c b/src/journal-remote/journal-remote-parse.c
index 00f14c765aa..32a3e775648 100644
--- a/src/journal-remote/journal-remote-parse.c
+++ b/src/journal-remote/journal-remote-parse.c
@@ -1,10 +1,14 @@
/* SPDX-License-Identifier: LGPL-2.1-or-later */
+#include
+
#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,
diff --git a/src/journal-remote/journal-remote-parse.h b/src/journal-remote/journal-remote-parse.h
index 54b984431eb..6800b46a042 100644
--- a/src/journal-remote/journal-remote-parse.h
+++ b/src/journal-remote/journal-remote-parse.h
@@ -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);
diff --git a/src/journal-remote/journal-remote.conf.in b/src/journal-remote/journal-remote.conf.in
index 595b93ca89d..573453f749e 100644
--- a/src/journal-remote/journal-remote.conf.in
+++ b/src/journal-remote/journal-remote.conf.in
@@ -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=
diff --git a/src/journal-remote/journal-remote.h b/src/journal-remote/journal-remote.h
index bcf619332c2..3b54934dd0b 100644
--- a/src/journal-remote/journal-remote.h
+++ b/src/journal-remote/journal-remote.h
@@ -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;
diff --git a/test/units/TEST-04-JOURNAL.journal-remote.sh b/test/units/TEST-04-JOURNAL.journal-remote.sh
index 3772ffc3808..15eb711474a 100755
--- a/test/units/TEST-04-JOURNAL.journal-remote.sh
+++ b/test/units/TEST-04-JOURNAL.journal-remote.sh
@@ -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)"