From b02dd492eb94cdb516126b91c16ee271a940ced4 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Tue, 21 Jul 2026 10:09:42 +0200 Subject: [PATCH] Deliver aggregate SMTP TLS reports --- cmake/Requirements.cmake | 1 + debian/control | 1 + docs/release-testing-0.7.md | 2 + docs/tls.md | 19 + include/bongotlsrpt.h | 47 ++ src/agents/worker/CMakeLists.txt | 22 + src/agents/worker/jobs/registry.c | 3 + .../worker/jobs/tlsrpt-delivery-support.c | 347 ++++++++++++++ .../worker/jobs/tlsrpt-delivery-support.h | 33 ++ src/agents/worker/jobs/tlsrpt-delivery.c | 448 ++++++++++++++++++ .../worker/tests/tlsrpt-delivery-test.c | 80 ++++ src/agents/worker/tests/worker-config-test.c | 9 +- src/apps/config/config/worker | 11 + src/apps/config/tests/test_configuration.py | 19 + src/apps/config/tests/test_storage.py | 2 +- src/libs/python/bongo/configuration/model.py | 32 +- src/libs/tlsrpt/tests/policy-test.c | 4 +- src/libs/tlsrpt/tests/store-test.c | 63 +++ src/libs/tlsrpt/tlsrpt.c | 320 ++++++++++++- 19 files changed, 1454 insertions(+), 9 deletions(-) create mode 100644 src/agents/worker/jobs/tlsrpt-delivery-support.c create mode 100644 src/agents/worker/jobs/tlsrpt-delivery-support.h create mode 100644 src/agents/worker/jobs/tlsrpt-delivery.c create mode 100644 src/agents/worker/tests/tlsrpt-delivery-test.c diff --git a/cmake/Requirements.cmake b/cmake/Requirements.cmake index 33ee962..6fa7257 100644 --- a/cmake/Requirements.cmake +++ b/cmake/Requirements.cmake @@ -85,6 +85,7 @@ endif() # Secure IMAP, POP3 and SMTP client transport. find_package(CURL 7.61 REQUIRED) +find_package(ZLIB REQUIRED) # Native DNS queries, authoritative propagation checks and RFC 2136 updates # used by ACME DNS-01 providers. diff --git a/debian/control b/debian/control index 3b71a03..60277be 100644 --- a/debian/control +++ b/debian/control @@ -22,6 +22,7 @@ Build-Depends: libgnutls28-dev, libsqlite3-dev, libcurl4-gnutls-dev, + zlib1g-dev, libldns-dev, libmailutils-dev (>= 1:3.19-1+bongo1), libgsasl-dev (>= 2.2.0), diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 0b50d22..d5cada3 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -146,6 +146,8 @@ inputs and outputs is absent from the ZIP. | SMTP-23 | Fallback client-address header only for configured trusted peers | | | | | SMTP-24 | Incoming PROXY v1/v2 only from configured proxy networks | | | | | SMTP-25 | Malformed commands, long lines, floods, timeout, and disconnect | | | | +| SMTP-28 | TLSRPT preserves the exact DNS policy, aggregates completed UTC days, and emits valid RFC 8460 JSON | | | | +| SMTP-29 | TLSRPT gzip mail/HTTPS delivery, DKIM requirement, random delay, durable retry, expiry, and report-loop suppression | | | | ## IMAP4rev2, IMAP4rev1, and legacy IMAP4 diff --git a/docs/tls.md b/docs/tls.md index bf4386c..d5ebf9d 100644 --- a/docs/tls.md +++ b/docs/tls.md @@ -39,6 +39,25 @@ cached policy remains authoritative through its `max_age` when DNS discovery or HTTPS refresh temporarily fails. DANE always takes precedence over MTA-STS. +When outbound TLS reporting is enabled, the SMTP delivery agent records one +final TLS policy result for each attempted destination. The worker aggregates +completed UTC days into RFC 8460 JSON reports without losing late events or +double-counting retries. It preserves the exact DNS TLSRPT policy that was +used for delivery, waits a randomized interval of up to four hours, and sends +one durable delivery to every `mailto:` and `https:` reporting URI. Reports +are gzip-compressed; mail reports use `multipart/report` with an +`application/tlsrpt+gzip` attachment, while HTTPS reports use a validated +HTTPS POST. Temporary failures are retried with persisted exponential backoff +for 24 hours after the initial attempt. + +TLS report messages carry `TLS-Required: No` and bypass outbound DANE, +MTA-STS, and TLSRPT accounting so a receiver's TLS failure cannot create a +report loop. They still prefer STARTTLS. RFC 8460 requires email reports to be +DKIM-signed, so `mailto:` delivery remains pending unless outbound DKIM is +enabled and the configured key for the reporting domain is readable. The +submitter defaults to the DKIM signing domain, or otherwise to the configured +Bongo host name. HTTPS report delivery does not depend on DKIM. + The resolver named in `resolv.conf` must therefore be a trusted validating resolver reached over a trusted local path; Bongo must not trust an AD bit received from an untrusted network resolver. GnuTLS/libdane also needs its diff --git a/include/bongotlsrpt.h b/include/bongotlsrpt.h index f831889..604d698 100644 --- a/include/bongotlsrpt.h +++ b/include/bongotlsrpt.h @@ -96,6 +96,25 @@ typedef struct BongoTlsRptEvent { typedef struct BongoTlsRptStore BongoTlsRptStore; +typedef struct BongoTlsRptDelivery { + int64_t id; + int endpoint_type; + int attempts; + int64_t day_start; + int64_t created_at; + int64_t expires_at; + char report_domain[BONGO_TLSRPT_DOMAIN_SIZE]; + char report_id[BONGO_TLSRPT_DOMAIN_SIZE * 2]; + char endpoint_uri[BONGO_TLSRPT_URI_SIZE]; + char *json; +} BongoTlsRptDelivery; + +typedef enum BongoTlsRptDeliveryResult { + BONGO_TLSRPT_DELIVERY_RETRY = 0, + BONGO_TLSRPT_DELIVERY_SUCCESS = 1, + BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE = 2 +} BongoTlsRptDeliveryResult; + /* Parse one complete TXT value after DNS character-string concatenation. */ int BongoTlsRptParseRecord(const char *record, BongoTlsRptDnsPolicy *policy); @@ -127,6 +146,34 @@ int BongoTlsRptStoreDeleteReport(BongoTlsRptStore *store, int64_t day_start, const char *reporting_policy); +/* + * Atomically turn the oldest completed reporting-policy group into one + * durable delivery per RUA endpoint. The corresponding event rows are + * removed in the same SQLite write transaction, so events recorded after + * the snapshot cannot be lost. Returns 1 when a batch was prepared, 0 when + * no complete day is available, and -1 on error. + */ +int BongoTlsRptStorePrepareNext(BongoTlsRptStore *store, + int64_t before_day_start, + const char *organization_name, + const char *contact_info, + const char *submitter, + int64_t now, + int64_t initial_not_before); + +/* Returns 1 for a due delivery, 0 when none is due, and -1 on error. */ +int BongoTlsRptStoreNextDelivery(BongoTlsRptStore *store, int64_t now, + BongoTlsRptDelivery *delivery, + int64_t *next_not_before); +void BongoTlsRptDeliveryFree(BongoTlsRptDelivery *delivery); + +int BongoTlsRptStoreFinishDelivery(BongoTlsRptStore *store, + int64_t delivery_id, + BongoTlsRptDeliveryResult result, + int64_t next_attempt, + const char *error); +int BongoTlsRptStoreCleanupExpired(BongoTlsRptStore *store, int64_t now); + const char *BongoTlsRptPolicyTypeName(BongoTlsRptPolicyType type); const char *BongoTlsRptResultTypeName(BongoTlsRptResultType type); diff --git a/src/agents/worker/CMakeLists.txt b/src/agents/worker/CMakeLists.txt index 9c71c3c..9aae499 100644 --- a/src/agents/worker/CMakeLists.txt +++ b/src/agents/worker/CMakeLists.txt @@ -7,6 +7,8 @@ add_executable(bongoworker jobs/acme-renew.c jobs/scanner-health.c jobs/scanner-health-support.c + jobs/tlsrpt-delivery.c + jobs/tlsrpt-delivery-support.c ) target_include_directories(bongoworker PRIVATE @@ -26,6 +28,9 @@ target_link_libraries(bongoworker bongoconnio bongocal SQLite3::SQLite3 + bongotlsrpt + CURL::libcurl + ZLIB::ZLIB Threads::Threads ) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") @@ -42,6 +47,8 @@ if(BUILD_TESTING) jobs/acme-renew.c jobs/scanner-health.c jobs/scanner-health-support.c + jobs/tlsrpt-delivery.c + jobs/tlsrpt-delivery-support.c ) target_include_directories(worker-config-test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} @@ -58,6 +65,9 @@ if(BUILD_TESTING) bongomsgapi bongocal SQLite3::SQLite3 + bongotlsrpt + CURL::libcurl + ZLIB::ZLIB GnuTLS::GnuTLS Threads::Threads ) @@ -77,4 +87,16 @@ if(BUILD_TESTING) SQLite3::SQLite3 ) add_test(NAME worker-scanner-health COMMAND worker-scanner-health-test) + + add_executable(worker-tlsrpt-delivery-test + tests/tlsrpt-delivery-test.c + jobs/tlsrpt-delivery-support.c + ) + target_include_directories(worker-tlsrpt-delivery-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/jobs + ) + target_link_libraries(worker-tlsrpt-delivery-test PRIVATE + ZLIB::ZLIB + ) + add_test(NAME worker-tlsrpt-delivery COMMAND worker-tlsrpt-delivery-test) endif() diff --git a/src/agents/worker/jobs/registry.c b/src/agents/worker/jobs/registry.c index 515fb2d..c7a8eee 100644 --- a/src/agents/worker/jobs/registry.c +++ b/src/agents/worker/jobs/registry.c @@ -27,10 +27,13 @@ int BongoWorkerRunAcmeRenew(char *error, size_t error_size, int64_t *not_before); int BongoWorkerRunScannerHealth(char *error, size_t error_size, int64_t *not_before); +int BongoWorkerRunTlsRptDelivery(char *error, size_t error_size, + int64_t *not_before); static const BongoWorkerJob Jobs[] = { {"acme-renew", BongoWorkerRunAcmeRenew}, {"scanner-health", BongoWorkerRunScannerHealth}, + {"tlsrpt-delivery", BongoWorkerRunTlsRptDelivery}, {NULL, NULL} }; diff --git a/src/agents/worker/jobs/tlsrpt-delivery-support.c b/src/agents/worker/jobs/tlsrpt-delivery-support.c new file mode 100644 index 0000000..a5b1b6b --- /dev/null +++ b/src/agents/worker/jobs/tlsrpt-delivery-support.c @@ -0,0 +1,347 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License. + * + ****************************************************************************/ + +#include "tlsrpt-delivery-support.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +typedef struct Buffer { + char *data; + size_t length; + size_t capacity; +} Buffer; + +static void +SetError(char *error, size_t error_size, const char *format, ...) +{ + va_list arguments; + + if (!error || error_size == 0U) return; + va_start(arguments, format); + vsnprintf(error, error_size, format, arguments); + va_end(arguments); +} + +static int +Reserve(Buffer *buffer, size_t extra) +{ + size_t needed; + size_t capacity; + char *replacement; + + if (extra > SIZE_MAX - buffer->length - 1U) return 0; + needed = buffer->length + extra + 1U; + if (needed <= buffer->capacity) return 1; + capacity = buffer->capacity ? buffer->capacity : 1024U; + while (capacity < needed) { + if (capacity > SIZE_MAX / 2U) { + capacity = needed; + break; + } + capacity *= 2U; + } + replacement = realloc(buffer->data, capacity); + if (!replacement) return 0; + buffer->data = replacement; + buffer->capacity = capacity; + return 1; +} + +static int +AppendN(Buffer *buffer, const char *data, size_t length) +{ + if (!Reserve(buffer, length)) return 0; + memcpy(buffer->data + buffer->length, data, length); + buffer->length += length; + buffer->data[buffer->length] = '\0'; + return 1; +} + +static int +Append(Buffer *buffer, const char *data) +{ + return AppendN(buffer, data, strlen(data)); +} + +static int +AppendF(Buffer *buffer, const char *format, ...) +{ + va_list arguments; + va_list copied; + int length; + + va_start(arguments, format); + va_copy(copied, arguments); + length = vsnprintf(NULL, 0, format, copied); + va_end(copied); + if (length < 0 || !Reserve(buffer, (size_t)length)) { + va_end(arguments); + return 0; + } + (void)vsnprintf(buffer->data + buffer->length, + buffer->capacity - buffer->length, format, arguments); + va_end(arguments); + buffer->length += (size_t)length; + return 1; +} + +static int +HeaderSafe(const char *value) +{ + const unsigned char *cursor = (const unsigned char *)value; + + if (!cursor || !*cursor || strlen(value) > 900U) return 0; + for (; *cursor; cursor++) + if (*cursor < 0x21 || *cursor == 0x7f) return 0; + return 1; +} + +int +BongoWorkerTlsRptGzip(const char *json, unsigned char **compressed, + size_t *compressed_size, + char *error, size_t error_size) +{ + z_stream stream; + size_t input_size; + uLong bound; + int status; + int result = 0; + + if (error && error_size) error[0] = '\0'; + if (!json || !compressed || !compressed_size) return 0; + *compressed = NULL; + *compressed_size = 0U; + input_size = strlen(json); + if (input_size > UINT_MAX) { + SetError(error, error_size, "TLSRPT JSON is too large to compress"); + return 0; + } + memset(&stream, 0, sizeof(stream)); + status = deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, + MAX_WBITS + 16, 8, Z_DEFAULT_STRATEGY); + if (status != Z_OK) { + SetError(error, error_size, "cannot initialize gzip compressor"); + return 0; + } + bound = deflateBound(&stream, (uLong)input_size); + if (bound > UINT_MAX) { + SetError(error, error_size, "compressed TLSRPT report is too large"); + goto done; + } + *compressed = malloc((size_t)bound ? (size_t)bound : 1U); + if (!*compressed) { + SetError(error, error_size, "out of memory compressing TLSRPT report"); + goto done; + } + stream.next_in = (Bytef *)(uintptr_t)json; + stream.avail_in = (uInt)input_size; + stream.next_out = *compressed; + stream.avail_out = (uInt)bound; + status = deflate(&stream, Z_FINISH); + if (status != Z_STREAM_END) { + SetError(error, error_size, "gzip compression failed"); + goto done; + } + *compressed_size = (size_t)stream.total_out; + result = 1; + +done: + deflateEnd(&stream); + if (!result) { + free(*compressed); + *compressed = NULL; + *compressed_size = 0U; + } + return result; +} + +static int +HexDigit(unsigned char value) +{ + if (value >= '0' && value <= '9') return value - '0'; + value = (unsigned char)tolower(value); + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + return -1; +} + +int +BongoWorkerTlsRptDecodeMailto(const char *uri, char *recipient, + size_t recipient_size) +{ + const unsigned char *cursor; + size_t length = 0U; + + if (!uri || strncmp(uri, "mailto:", 7) != 0 || !recipient || + recipient_size == 0U) return 0; + cursor = (const unsigned char *)uri + 7U; + while (*cursor && *cursor != '?') { + unsigned char value = *cursor++; + if (value == '%') { + int high = HexDigit(cursor[0]); + int low = HexDigit(cursor[1]); + if (high < 0 || low < 0) return 0; + value = (unsigned char)((high << 4) | low); + cursor += 2; + } + if (value <= 0x20 || value == 0x7f || value == ',' || value == ';' || + length + 1U >= recipient_size) return 0; + recipient[length++] = (char)value; + } + recipient[length] = '\0'; + return length != 0U && strchr(recipient, '@') != NULL; +} + +static int +AppendBase64(Buffer *buffer, const unsigned char *data, size_t length) +{ + static const char Alphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + size_t index = 0U; + unsigned int column = 0U; + + while (index < length) { + size_t remaining = length - index; + unsigned int first = data[index++]; + unsigned int second = remaining > 1U ? data[index++] : 0U; + unsigned int third = remaining > 2U ? data[index++] : 0U; + char encoded[4]; + + encoded[0] = Alphabet[first >> 2]; + encoded[1] = Alphabet[((first & 3U) << 4) | (second >> 4)]; + encoded[2] = remaining > 1U + ? Alphabet[((second & 15U) << 2) | (third >> 6)] : '='; + encoded[3] = remaining > 2U ? Alphabet[third & 63U] : '='; + if (!AppendN(buffer, encoded, sizeof(encoded))) return 0; + column += 4U; + if (column == 76U) { + if (!Append(buffer, "\r\n")) return 0; + column = 0U; + } + } + return column == 0U || Append(buffer, "\r\n"); +} + +static int +FormatDate(int64_t value, char output[64]) +{ + static const char *const Days[] = + {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + static const char *const Months[] = + {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + time_t converted = (time_t)value; + struct tm broken_down; + + if ((int64_t)converted != value || + !gmtime_r(&converted, &broken_down) || + broken_down.tm_wday < 0 || broken_down.tm_wday > 6 || + broken_down.tm_mon < 0 || broken_down.tm_mon > 11) return 0; + return snprintf(output, 64, "%s, %02d %s %04d %02d:%02d:%02d +0000", + Days[broken_down.tm_wday], broken_down.tm_mday, + Months[broken_down.tm_mon], broken_down.tm_year + 1900, + broken_down.tm_hour, broken_down.tm_min, + broken_down.tm_sec) < 64; +} + +int +BongoWorkerTlsRptBuildMessage(const BongoTlsRptDelivery *delivery, + const char *submitter, + const char *from_address, + const char *recipient, + int64_t now, + char **message, size_t *message_size, + char *error, size_t error_size) +{ + unsigned char *compressed = NULL; + size_t compressed_size = 0U; + Buffer buffer = {0}; + char date[64]; + int result = 0; + + if (error && error_size) error[0] = '\0'; + if (!delivery || !delivery->json || !message || !message_size || + !HeaderSafe(delivery->report_domain) || + !HeaderSafe(delivery->report_id) || !HeaderSafe(submitter) || + !HeaderSafe(from_address) || !HeaderSafe(recipient) || + delivery->day_start < 0 || + delivery->day_start > INT64_MAX - 86399 || !FormatDate(now, date)) { + SetError(error, error_size, "unsafe TLSRPT mail metadata"); + return 0; + } + *message = NULL; + *message_size = 0U; + if (!BongoWorkerTlsRptGzip(delivery->json, &compressed, + &compressed_size, error, error_size)) return 0; + if (!AppendF(&buffer, + "From: %s\r\nTo: %s\r\nDate: %s\r\n" + "Message-ID: <%s>\r\n" + "Subject: Report Domain: %s Submitter: %s Report-ID: <%s>\r\n" + "TLS-Report-Domain: %s\r\nTLS-Report-Submitter: %s\r\n" + "TLS-Required: No\r\nAuto-Submitted: auto-generated\r\n" + "MIME-Version: 1.0\r\n" + "Content-Type: multipart/report; report-type=\"tlsrpt\"; " + "boundary=\"=_bongo_tlsrpt_%" PRIi64 "\"\r\n\r\n" + "This is a multipart message in MIME format.\r\n\r\n" + "--=_bongo_tlsrpt_%" PRIi64 "\r\n" + "Content-Type: text/plain; charset=us-ascii\r\n" + "Content-Transfer-Encoding: 7bit\r\n\r\n" + "This is an aggregate TLS report from %s.\r\n\r\n" + "--=_bongo_tlsrpt_%" PRIi64 "\r\n" + "Content-Type: application/tlsrpt+gzip\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Content-Disposition: attachment; filename=\"%s!%s!%" PRIi64 + "!%" PRIi64 "!%" PRIi64 ".json.gz\"\r\n\r\n", + from_address, recipient, date, delivery->report_id, + delivery->report_domain, submitter, delivery->report_id, + delivery->report_domain, submitter, delivery->id, delivery->id, + submitter, delivery->id, submitter, delivery->report_domain, + delivery->day_start, delivery->day_start + 86399, delivery->id) || + !AppendBase64(&buffer, compressed, compressed_size) || + !AppendF(&buffer, "\r\n--=_bongo_tlsrpt_%" PRIi64 "--\r\n", + delivery->id)) { + SetError(error, error_size, "out of memory building TLSRPT message"); + goto done; + } + *message = buffer.data; + *message_size = buffer.length; + buffer.data = NULL; + result = 1; + +done: + free(compressed); + free(buffer.data); + return result; +} + +int64_t +BongoWorkerTlsRptRetryDelay(int attempts) +{ + int64_t delay = 300; + + if (attempts < 0) attempts = 0; + while (attempts-- > 0 && delay < 21600) delay *= 2; + return delay > 21600 ? 21600 : delay; +} + +BongoTlsRptDeliveryResult +BongoWorkerTlsRptHttpResult(long status) +{ + if (status >= 200 && status < 300) return BONGO_TLSRPT_DELIVERY_SUCCESS; + if (status == 408 || status == 425 || status == 429 || status >= 500) + return BONGO_TLSRPT_DELIVERY_RETRY; + return BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE; +} diff --git a/src/agents/worker/jobs/tlsrpt-delivery-support.h b/src/agents/worker/jobs/tlsrpt-delivery-support.h new file mode 100644 index 0000000..4b150d1 --- /dev/null +++ b/src/agents/worker/jobs/tlsrpt-delivery-support.h @@ -0,0 +1,33 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License. + * + ****************************************************************************/ + +#ifndef BONGO_WORKER_TLSRPT_DELIVERY_SUPPORT_H +#define BONGO_WORKER_TLSRPT_DELIVERY_SUPPORT_H + +#include + +#include +#include + +int BongoWorkerTlsRptGzip(const char *json, unsigned char **compressed, + size_t *compressed_size, + char *error, size_t error_size); +int BongoWorkerTlsRptDecodeMailto(const char *uri, char *recipient, + size_t recipient_size); +int BongoWorkerTlsRptBuildMessage(const BongoTlsRptDelivery *delivery, + const char *submitter, + const char *from_address, + const char *recipient, + int64_t now, + char **message, size_t *message_size, + char *error, size_t error_size); +int64_t BongoWorkerTlsRptRetryDelay(int attempts); +BongoTlsRptDeliveryResult BongoWorkerTlsRptHttpResult(long status); + +#endif diff --git a/src/agents/worker/jobs/tlsrpt-delivery.c b/src/agents/worker/jobs/tlsrpt-delivery.c new file mode 100644 index 0000000..ed871bc --- /dev/null +++ b/src/agents/worker/jobs/tlsrpt-delivery.c @@ -0,0 +1,448 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License. + * + ****************************************************************************/ + +#include + +#include "worker.h" +#include "tlsrpt-delivery-support.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define TLSRPT_MAX_BATCHES 64 +#define TLSRPT_MAX_DELIVERIES 64 +#define TLSRPT_INITIAL_DELAY_MAX 14400 + +typedef struct TlsRptConfiguration { + BongoJsonNode *smtp_node; + BongoJsonNode *global_node; + BongoJsonNode *worker_node; + int enabled; + int dkim_enabled; + const char *database; + const char *hostname; + const char *dkim_domain; + const char *dkim_selector; + const char *dkim_key_directory; + const char *organization_name; + char submitter[BONGO_TLSRPT_DOMAIN_SIZE]; + char from_address[BONGO_TLSRPT_DOMAIN_SIZE * 2]; + char contact_info[BONGO_TLSRPT_URI_SIZE]; +} TlsRptConfiguration; + +static int +ReadDocument(const char *name, BongoJsonNode **node, + char *error, size_t error_size) +{ + char *content = NULL; + int result = 0; + + *node = NULL; + if (!NMAPReadConfigFile(name, &content) || !content || + BongoJsonParseString(content, node) != BONGO_JSON_OK || !*node || + (*node)->type != BONGO_JSON_OBJECT) { + snprintf(error, error_size, + "cannot read valid %s configuration from Store", name); + goto done; + } + result = 1; +done: + if (content) MemFree(content); + if (!result && *node) { + BongoJsonNodeFree(*node); + *node = NULL; + } + return result; +} + +static void +FreeConfiguration(TlsRptConfiguration *configuration) +{ + if (configuration->global_node) + BongoJsonNodeFree(configuration->global_node); + if (configuration->worker_node) + BongoJsonNodeFree(configuration->worker_node); + if (configuration->smtp_node) + BongoJsonNodeFree(configuration->smtp_node); + memset(configuration, 0, sizeof(*configuration)); +} + +static int +SafePath(const char *path) +{ + size_t length; + + if (!path || path[0] != '/') return 0; + length = strlen(path); + return length > 1U && length < PATH_MAX && !strstr(path, "/../") && + !(length >= 3U && !strcmp(path + length - 3U, "/..")); +} + +static int +LoadConfiguration(TlsRptConfiguration *configuration, + char *error, size_t error_size) +{ + BongoJsonObject *smtp; + BongoJsonObject *global; + BongoJsonObject *worker; + BongoJsonObject *tls_reporting; + BOOL enabled = FALSE; + BOOL dkim_enabled = FALSE; + const char *domain; + + memset(configuration, 0, sizeof(*configuration)); + if (!ReadDocument("smtp", &configuration->smtp_node, + error, error_size) || + !ReadDocument("global", &configuration->global_node, + error, error_size) || + !ReadDocument("worker", &configuration->worker_node, + error, error_size)) return 0; + smtp = BongoJsonNodeAsObject(configuration->smtp_node); + global = BongoJsonNodeAsObject(configuration->global_node); + worker = BongoJsonNodeAsObject(configuration->worker_node); + if (BongoJsonObjectGetBool(smtp, "outbound_tlsrpt_enabled", &enabled) != + BONGO_JSON_OK || + BongoJsonObjectGetString(smtp, "outbound_tlsrpt_database", + &configuration->database) != BONGO_JSON_OK || + BongoJsonObjectGetBool(smtp, "dkim_sign_outgoing", &dkim_enabled) != + BONGO_JSON_OK || + BongoJsonObjectGetString(smtp, "dkim_signing_domain", + &configuration->dkim_domain) != BONGO_JSON_OK || + BongoJsonObjectGetString(smtp, "dkim_selector", + &configuration->dkim_selector) != BONGO_JSON_OK || + BongoJsonObjectGetString(smtp, "dkim_key_directory", + &configuration->dkim_key_directory) != + BONGO_JSON_OK || + BongoJsonObjectGetString(global, "hostname", + &configuration->hostname) != BONGO_JSON_OK || + BongoJsonObjectGetObject(worker, "tls_reporting", &tls_reporting) != + BONGO_JSON_OK || + BongoJsonObjectGetString(tls_reporting, "organization_name", + &configuration->organization_name) != + BONGO_JSON_OK || !configuration->organization_name || + !configuration->organization_name[0] || + strlen(configuration->organization_name) > 1023U || + !SafePath(configuration->database)) { + snprintf(error, error_size, "invalid TLSRPT worker configuration"); + return 0; + } + configuration->enabled = enabled != FALSE; + configuration->dkim_enabled = dkim_enabled != FALSE; + domain = configuration->dkim_domain && configuration->dkim_domain[0] + ? configuration->dkim_domain : configuration->hostname; + if (!BongoDomainToASCII(domain, configuration->submitter, + sizeof(configuration->submitter)) || + snprintf(configuration->from_address, + sizeof(configuration->from_address), "tlsrpt@%s", + configuration->submitter) >= + (int)sizeof(configuration->from_address) || + snprintf(configuration->contact_info, + sizeof(configuration->contact_info), "mailto:%s", + configuration->from_address) >= + (int)sizeof(configuration->contact_info)) { + snprintf(error, error_size, "invalid TLSRPT reporting domain"); + return 0; + } + return 1; +} + +static size_t +DiscardResponse(char *data, size_t size, size_t count, void *context) +{ + (void)data; + (void)context; + if (size != 0U && count > SIZE_MAX / size) return 0U; + return size * count; +} + +static BongoTlsRptDeliveryResult +DeliverHttps(const BongoTlsRptDelivery *delivery, + char *error, size_t error_size) +{ + unsigned char *compressed = NULL; + size_t compressed_size = 0U; + struct curl_slist *headers = NULL; + CURL *curl = NULL; + CURLcode status; + long response = 0; + BongoTlsRptDeliveryResult result = BONGO_TLSRPT_DELIVERY_RETRY; + + if (!BongoWorkerTlsRptGzip(delivery->json, &compressed, &compressed_size, + error, error_size)) return result; + curl = curl_easy_init(); + headers = curl_slist_append(headers, + "Content-Type: application/tlsrpt+gzip"); + if (!curl || !headers) { + snprintf(error, error_size, "cannot initialize HTTPS delivery"); + goto done; + } + curl_easy_setopt(curl, CURLOPT_URL, delivery->endpoint_uri); +#if LIBCURL_VERSION_NUM >= 0x075500 + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, (long)CURLPROTO_HTTPS); +#endif + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, compressed); + curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, + (curl_off_t)compressed_size); + curl_easy_setopt(curl, CURLOPT_USERAGENT, + "Bongo/" BONGO_VERSION " TLSRPT"); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, DiscardResponse); + status = curl_easy_perform(curl); + if (status != CURLE_OK) { + snprintf(error, error_size, "HTTPS delivery failed: %s", + curl_easy_strerror(status)); + if (status == CURLE_UNSUPPORTED_PROTOCOL || status == CURLE_URL_MALFORMAT) + result = BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE; + goto done; + } + if (curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response) != CURLE_OK) { + snprintf(error, error_size, "cannot read HTTPS response status"); + goto done; + } + result = BongoWorkerTlsRptHttpResult(response); + if (result != BONGO_TLSRPT_DELIVERY_SUCCESS) + snprintf(error, error_size, "HTTPS endpoint returned status %ld", + response); + +done: + curl_slist_free_all(headers); + if (curl) curl_easy_cleanup(curl); + free(compressed); + return result; +} + +static int +QueueCommand(Connection *connection, char *response, size_t response_size, + const char *format, ...) +{ + va_list arguments; + int written; + + va_start(arguments, format); + written = ConnWriteVF(connection, format, arguments); + va_end(arguments); + if (written < 0 || ConnFlush(connection) < 0) return -1; + return NMAPReadResponse(connection, response, response_size, TRUE); +} + +static BongoTlsRptDeliveryResult +DeliverMail(const TlsRptConfiguration *configuration, + const BongoTlsRptDelivery *delivery, + int64_t now, char *error, size_t error_size) +{ + Connection *connection = NULL; + char recipient[BONGO_TLSRPT_URI_SIZE]; + char response[CONN_BUFSIZE]; + char key_path[PATH_MAX]; + char *message = NULL; + size_t message_size = 0U; + BongoTlsRptDeliveryResult result = BONGO_TLSRPT_DELIVERY_RETRY; + + if (!BongoWorkerTlsRptDecodeMailto(delivery->endpoint_uri, recipient, + sizeof(recipient))) { + snprintf(error, error_size, "invalid TLSRPT mailto endpoint"); + return BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE; + } + if (!configuration->dkim_enabled || !configuration->dkim_selector || + !configuration->dkim_selector[0] || + !configuration->dkim_key_directory || + snprintf(key_path, sizeof(key_path), "%s/%s/%s.private", + configuration->dkim_key_directory, configuration->submitter, + configuration->dkim_selector) >= (int)sizeof(key_path) || + access(key_path, R_OK) != 0) { + snprintf(error, error_size, + "RFC 8460 mail delivery requires a readable DKIM key for %s", + configuration->submitter); + return result; + } + if (!BongoWorkerTlsRptBuildMessage( + delivery, configuration->submitter, configuration->from_address, + recipient, now, &message, &message_size, error, error_size)) + return result; + connection = NMAPConnectQueue("127.0.0.1", NULL); + if (!connection || + !NMAPAuthenticateToQueue(connection, response, sizeof(response))) { + snprintf(error, error_size, "cannot authenticate to Bongo queue"); + goto done; + } + if (QueueCommand(connection, response, sizeof(response), "QCREA\r\n") != + 1000 || + NMAPSendCommandF(connection, "QSTOR MESSAGE %" PRIu64 "\r\n", + (uint64_t)message_size) < 0 || + NMAPSendCommand(connection, message, message_size) < 0 || + NMAPReadResponse(connection, response, sizeof(response), TRUE) != 1000 || + QueueCommand(connection, response, sizeof(response), + "QSTOR TO %s %s 2\r\n", recipient, recipient) != 1000 || + QueueCommand(connection, response, sizeof(response), + "QSTOR FROM %s -\r\n", + configuration->from_address) != 1000 || + QueueCommand(connection, response, sizeof(response), + "QSTOR FLAGS %d\r\n", MSG_FLAG_TLS_REPORT) != 1000 || + QueueCommand(connection, response, sizeof(response), "QRUN\r\n") != + 1000) { + snprintf(error, error_size, "Bongo queue rejected TLSRPT report"); + goto done; + } + result = BONGO_TLSRPT_DELIVERY_SUCCESS; + +done: + if (connection) NMAPQuit(connection); + free(message); + return result; +} + +static unsigned int +InitialDelay(int64_t now) +{ + unsigned int seed = (unsigned int)now ^ + (unsigned int)((uint64_t)now >> 32) ^ (unsigned int)getpid(); + + return 1U + rand_r(&seed) % TLSRPT_INITIAL_DELAY_MAX; +} + +int +BongoWorkerRunTlsRptDelivery(char *error, size_t error_size, + int64_t *not_before) +{ + TlsRptConfiguration configuration; + BongoTlsRptStore *store = NULL; + BongoTlsRptDelivery delivery; + int64_t now = (int64_t)time(NULL); + int64_t today; + int64_t next_due = 0; + int prepared; + int delivered; + int curl_initialized = 0; + int result = 0; + + if (error && error_size) error[0] = '\0'; + if (not_before) *not_before = 0; + memset(&configuration, 0, sizeof(configuration)); + if (now < 0 || !LoadConfiguration(&configuration, error, error_size)) { + FreeConfiguration(&configuration); + return 0; + } + if (!configuration.enabled) { + FreeConfiguration(&configuration); + return 1; + } + if (!BongoTlsRptStoreOpen(&store, configuration.database)) { + snprintf(error, error_size, "cannot open TLSRPT database %s", + configuration.database); + goto done; + } + if (!BongoTlsRptStoreCleanupExpired(store, now)) { + snprintf(error, error_size, "cannot expire old TLSRPT deliveries"); + goto done; + } + today = (now / 86400) * 86400; + for (prepared = 0; prepared < TLSRPT_MAX_BATCHES; prepared++) { + int status = BongoTlsRptStorePrepareNext( + store, today, configuration.organization_name, + configuration.contact_info, + configuration.submitter, now, now + InitialDelay(now + prepared)); + if (status < 0) { + snprintf(error, error_size, "cannot prepare TLSRPT report batch"); + goto done; + } + if (status == 0) break; + } + if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) { + snprintf(error, error_size, "cannot initialize HTTPS client"); + goto done; + } + curl_initialized = 1; + for (delivered = 0; delivered < TLSRPT_MAX_DELIVERIES; delivered++) { + BongoTlsRptDeliveryResult delivery_result; + int status = BongoTlsRptStoreNextDelivery(store, now, &delivery, + &next_due); + char delivery_error[BONGO_WORKER_ERROR_SIZE] = {0}; + int64_t retry_at = 0; + + if (status < 0) { + snprintf(error, error_size, "cannot read pending TLSRPT delivery"); + goto done; + } + if (status == 0) break; + if (delivery.endpoint_type == BONGO_TLSRPT_ENDPOINT_MAILTO) + delivery_result = DeliverMail(&configuration, &delivery, now, + delivery_error, + sizeof(delivery_error)); + else if (delivery.endpoint_type == BONGO_TLSRPT_ENDPOINT_HTTPS) + delivery_result = DeliverHttps(&delivery, delivery_error, + sizeof(delivery_error)); + else { + delivery_result = BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE; + snprintf(delivery_error, sizeof(delivery_error), + "unsupported TLSRPT endpoint type"); + } + if (delivery_result == BONGO_TLSRPT_DELIVERY_RETRY) { + retry_at = now + BongoWorkerTlsRptRetryDelay(delivery.attempts); + if (retry_at >= delivery.expires_at) + delivery_result = BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE; + } + if (!BongoTlsRptStoreFinishDelivery( + store, delivery.id, delivery_result, retry_at, + delivery_error)) { + BongoTlsRptDeliveryFree(&delivery); + snprintf(error, error_size, "cannot persist TLSRPT delivery state"); + goto done; + } + if (delivery_result != BONGO_TLSRPT_DELIVERY_SUCCESS) + Log(LOG_WARNING, "TLSRPT delivery to %.400s failed: %.400s", + delivery.endpoint_uri, + delivery_error[0] ? delivery_error : "unknown error"); + BongoTlsRptDeliveryFree(&delivery); + } + curl_global_cleanup(); + curl_initialized = 0; + { + int pending = BongoTlsRptStoreNextDelivery(store, now, &delivery, + &next_due); + if (pending < 0) { + snprintf(error, error_size, + "cannot schedule pending TLSRPT delivery"); + goto done; + } + if (pending == 1) next_due = now + 1; + } + BongoTlsRptDeliveryFree(&delivery); + if (next_due != 0 && not_before) + *not_before = next_due > now ? next_due : now + 1; + result = 1; + +done: + if (curl_initialized) curl_global_cleanup(); + BongoTlsRptStoreClose(&store); + FreeConfiguration(&configuration); + return result; +} diff --git a/src/agents/worker/tests/tlsrpt-delivery-test.c b/src/agents/worker/tests/tlsrpt-delivery-test.c new file mode 100644 index 0000000..df4ed1a --- /dev/null +++ b/src/agents/worker/tests/tlsrpt-delivery-test.c @@ -0,0 +1,80 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License. + * + ****************************************************************************/ + +#include "tlsrpt-delivery-support.h" + +#include +#include +#include + +int +main(void) +{ + BongoTlsRptDelivery delivery = { + .id = 42, + .endpoint_type = BONGO_TLSRPT_ENDPOINT_MAILTO, + .day_start = 172800, + .created_at = 300000, + .expires_at = 386400, + .report_domain = "receiver.example", + .report_id = "tlsrpt-172800-42@mail.sender.example", + .endpoint_uri = "mailto:tls%2brpt@receiver.example", + .json = "{\"organization-name\":\"Bongo\"}" + }; + unsigned char *compressed = NULL; + size_t compressed_size = 0U; + char recipient[256]; + char error[256]; + char *message = NULL; + size_t message_size = 0U; + + assert(BongoWorkerTlsRptGzip(delivery.json, &compressed, + &compressed_size, error, sizeof(error))); + assert(compressed_size > 18U); + assert(compressed[0] == 0x1f && compressed[1] == 0x8b); + free(compressed); + + assert(BongoWorkerTlsRptDecodeMailto(delivery.endpoint_uri, recipient, + sizeof(recipient))); + assert(strcmp(recipient, "tls+rpt@receiver.example") == 0); + assert(!BongoWorkerTlsRptDecodeMailto( + "mailto:reports%0d%0aBcc:x@example.test", recipient, + sizeof(recipient))); + assert(BongoWorkerTlsRptBuildMessage( + &delivery, "mail.sender.example", "tlsrpt@mail.sender.example", + "tls+rpt@receiver.example", 300000, &message, &message_size, + error, sizeof(error))); + assert(message_size == strlen(message)); + assert(strstr(message, "TLS-Required: No\r\n") != NULL); + assert(strstr(message, + "Content-Type: application/tlsrpt+gzip\r\n") != NULL); + assert(strstr(message, + "boundary=\"=_bongo_tlsrpt_42\"\r\n") != NULL); + assert(strstr(message, + "TLS-Report-Domain: receiver.example\r\n") != NULL); + assert(strstr(message, + "TLS-Report-Submitter: mail.sender.example\r\n") != NULL); + assert(strstr(message, + "mail.sender.example!receiver.example!172800!259199!42.json.gz") != + NULL); + free(message); + + assert(BongoWorkerTlsRptRetryDelay(0) == 300); + assert(BongoWorkerTlsRptRetryDelay(1) == 600); + assert(BongoWorkerTlsRptRetryDelay(20) == 21600); + assert(BongoWorkerTlsRptHttpResult(200) == + BONGO_TLSRPT_DELIVERY_SUCCESS); + assert(BongoWorkerTlsRptHttpResult(429) == + BONGO_TLSRPT_DELIVERY_RETRY); + assert(BongoWorkerTlsRptHttpResult(503) == + BONGO_TLSRPT_DELIVERY_RETRY); + assert(BongoWorkerTlsRptHttpResult(404) == + BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE); + return 0; +} diff --git a/src/agents/worker/tests/worker-config-test.c b/src/agents/worker/tests/worker-config-test.c index ae10177..e6ac4c9 100644 --- a/src/agents/worker/tests/worker-config-test.c +++ b/src/agents/worker/tests/worker-config-test.c @@ -42,6 +42,9 @@ main(void) "\"retry_initial_seconds\":300,\"retry_max_seconds\":21600},{" "\"name\":\"scanner-health\",\"enabled\":true," "\"interval_seconds\":21600,\"jitter_seconds\":900," + "\"retry_initial_seconds\":300,\"retry_max_seconds\":21600},{" + "\"name\":\"tlsrpt-delivery\",\"enabled\":true," + "\"interval_seconds\":3600,\"jitter_seconds\":300," "\"retry_initial_seconds\":300,\"retry_max_seconds\":21600}]}"; static const char Unknown[] = "{\"poll_interval_seconds\":30,\"jobs\":[{" @@ -57,12 +60,14 @@ main(void) failed |= Check(BongoWorkerParseConfiguration( Valid, &poll, &jobs, &count, error, sizeof(error)), "valid worker configuration was rejected"); - failed |= Check(!failed && poll == 30 && count == 2 && + failed |= Check(!failed && poll == 30 && count == 3 && strcmp(jobs[0].name, "acme-renew") == 0 && jobs[0].enabled == 0 && jobs[0].interval_seconds == 43200 && strcmp(jobs[1].name, "scanner-health") == 0 && - jobs[1].enabled != 0, + jobs[1].enabled != 0 && + strcmp(jobs[2].name, "tlsrpt-delivery") == 0 && + jobs[2].enabled != 0, "valid worker configuration was parsed incorrectly"); BongoWorkerFreeConfiguration(&jobs, count); count = 0; diff --git a/src/apps/config/config/worker b/src/apps/config/config/worker index e4bf26a..22b36bd 100644 --- a/src/apps/config/config/worker +++ b/src/apps/config/config/worker @@ -11,6 +11,9 @@ "/var/db/spamassassin" ] }, + "tls_reporting": { + "organization_name": "Bongo" + }, "jobs": [ { "name": "acme-renew", @@ -27,6 +30,14 @@ "jitter_seconds": 900, "retry_initial_seconds": 300, "retry_max_seconds": 21600 + }, + { + "name": "tlsrpt-delivery", + "enabled": true, + "interval_seconds": 3600, + "jitter_seconds": 300, + "retry_initial_seconds": 300, + "retry_max_seconds": 21600 } ] } diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index 9327513..1a71051 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -177,6 +177,25 @@ class ConfigurationModelTest(unittest.TestCase): self.assertTrue(any("outbound_mta_sts_cache_directory" in message for message in messages)) + def test_tls_reporting_requires_registered_job_and_safe_organization(self): + defaults = templates() + configs = apply_scenario(defaults, Scenario( + hostname="mail.example.test", bind_address="192.0.2.10", + domains=["example.test"], admin_password="0123456789ab", + web_mode="direct", web_public_url="http://mail.example.test:8080", + )) + configs["worker"]["tls_reporting"]["organization_name"] = "bad\nname" + configs["worker"]["jobs"] = [ + job for job in configs["worker"]["jobs"] + if job["name"] != "tlsrpt-delivery" + ] + messages = [str(issue) for issue in + validate_all(configs, defaults, False)] + self.assertTrue(any("organization_name" in message + for message in messages)) + self.assertTrue(any("tlsrpt-delivery" in message + for message in messages)) + def test_direct_https_binds_443_and_redirects_80(self): defaults = templates() configs = apply_scenario(defaults, Scenario( diff --git a/src/apps/config/tests/test_storage.py b/src/apps/config/tests/test_storage.py index 90b1856..9bac675 100644 --- a/src/apps/config/tests/test_storage.py +++ b/src/apps/config/tests/test_storage.py @@ -171,7 +171,7 @@ class ConfigurationStorageTest(unittest.TestCase): result = merge_defaults(configurations, self.store.templates) self.assertEqual( [job["name"] for job in result["worker"]["jobs"]], - ["acme-renew", "scanner-health"], + ["acme-renew", "tlsrpt-delivery", "scanner-health"], ) def test_migration_moves_historical_files_and_writes_all_documents(self): diff --git a/src/libs/python/bongo/configuration/model.py b/src/libs/python/bongo/configuration/model.py index 71eb82a..f870227 100644 --- a/src/libs/python/bongo/configuration/model.py +++ b/src/libs/python/bongo/configuration/model.py @@ -65,7 +65,8 @@ _HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") _ATTRIBUTE_NAME = re.compile(r"^[A-Za-z0-9.-]{1,63}$") _SELECTOR = re.compile(r"^[A-Za-z0-9_-]{1,63}$") _DEVICE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$") -_WORKER_JOB_NAMES = frozenset({"acme-renew", "scanner-health"}) +_WORKER_JOB_NAMES = frozenset({"acme-renew", "scanner-health", + "tlsrpt-delivery"}) def _safe_absolute_path(value: Any) -> bool: @@ -691,6 +692,18 @@ def _validate_worker(config: Mapping[str, Any], issues: list[Issue]) -> None: _issue(issues, "worker", path + ".retry_max_seconds", "maximum retry delay must not be shorter than the initial delay") scanner_health = config.get("scanner_health") + tls_reporting = config.get("tls_reporting") + if not isinstance(tls_reporting, dict): + _issue(issues, "worker", "tls_reporting", + "expected a TLS reporting object") + else: + organization = tls_reporting.get("organization_name") + if (not isinstance(organization, str) or not organization or + len(organization.encode("utf-8")) > 1023 or + any(ord(character) < 0x20 or ord(character) == 0x7f + for character in organization)): + _issue(issues, "worker", "tls_reporting.organization_name", + "expected a non-empty control-free name of at most 1023 bytes") if not isinstance(scanner_health, dict): _issue(issues, "worker", "scanner_health", "expected a scanner health object") @@ -796,6 +809,22 @@ def _validate_acme(configurations: Mapping[str, Any], "disable the acme-renew job when ACME is disabled") +def _validate_tlsrpt(configurations: Mapping[str, Any], issues: list[Issue]) -> None: + smtp = configurations.get("smtp", {}) + if smtp.get("outbound_tlsrpt_enabled") is not True: + return + worker = configurations.get("worker", {}) + jobs = [job for job in worker.get("jobs", []) + if isinstance(job, dict) and job.get("name") == "tlsrpt-delivery"] + if len(jobs) != 1 or jobs[0].get("enabled") is not True: + _issue(issues, "worker", "jobs", + "outbound TLS reporting requires the tlsrpt-delivery worker job") + if smtp.get("dkim_sign_outgoing") is not True: + _issue(issues, "smtp", "dkim_sign_outgoing", + "TLS reports sent to mailto endpoints require DKIM signing", + "warning") + + def _validate_scanners(configurations: Mapping[str, Any], issues: list[Issue]) -> None: manager = configurations.get("manager", {}) enabled_agents = { @@ -994,6 +1023,7 @@ def validate_all(configurations: Mapping[str, Any], _validate_manager(configurations.get("manager", {}), issues) _validate_acme(configurations, issues) _validate_worker(configurations.get("worker", {}), issues) + _validate_tlsrpt(configurations, issues) _validate_scanners(configurations, issues) _validate_web(configurations.get("web", {}), issues, check_files) _validate_auth(configurations.get("authentication", {}), issues, check_files) diff --git a/src/libs/tlsrpt/tests/policy-test.c b/src/libs/tlsrpt/tests/policy-test.c index 9498e0e..9eb15cc 100644 --- a/src/libs/tlsrpt/tests/policy-test.c +++ b/src/libs/tlsrpt/tests/policy-test.c @@ -60,9 +60,9 @@ main(void) failed |= Check(!BongoTlsRptParseRecord( "v=TLSRPTv1; x-future=contains space; rua=mailto:r@example.test", &policy), "invalid extension value was accepted"); - failed |= Check(!BongoTlsRptParseRecord( + failed |= Check(BongoTlsRptParseRecord( "v=TLSRPTv1; rua=mailto:r!size@example.test", &policy), - "unencoded mailto URI delimiter was accepted"); + "RFC 6068 mailto local-part delimiter was rejected"); failed |= Check(!BongoTlsRptParseRecord( " v=TLSRPTv1; rua=mailto:r@example.test", &policy), "leading whitespace before the version was accepted"); diff --git a/src/libs/tlsrpt/tests/store-test.c b/src/libs/tlsrpt/tests/store-test.c index a671a13..59c93f1 100644 --- a/src/libs/tlsrpt/tests/store-test.c +++ b/src/libs/tlsrpt/tests/store-test.c @@ -48,7 +48,10 @@ main(void) "https://errors.example.test/cert", "certificate chain did not validate" }; BongoJsonNode *parsed = NULL; + BongoTlsRptDelivery delivery; + char first_report_id[BONGO_TLSRPT_DOMAIN_SIZE * 2] = ""; char *json = NULL; + int64_t next_not_before = 0; int descriptor; int failed = 0; @@ -85,6 +88,66 @@ main(void) "v=TLSRPTv1\t; rua=mailto:reports@example.test", "Bongo test", "mailto:postmaster@example.test", "empty", &json), "delivered TLSRPT rows were not removed"); + + failed |= Check(!failed && BongoTlsRptStoreRecord(store, &success) && + BongoTlsRptStorePrepareNext(store, 259200, "Bongo test", + "mailto:postmaster@example.test", "mail.example.test", + 300000, 300100) == 1, + "cannot atomically prepare a durable TLSRPT delivery"); + failed |= Check(!failed && + BongoTlsRptStoreNextDelivery(store, 300000, &delivery, + &next_not_before) == 0 && + next_not_before == 300100, + "TLSRPT delivery ignored its initial random delay"); + + /* A late event for the same day and policy must become a new report, + * never be deleted with the already prepared snapshot. */ + failed |= Check(!failed && BongoTlsRptStoreRecord(store, &success) && + BongoTlsRptStorePrepareNext(store, 259200, "Bongo test", + "mailto:postmaster@example.test", "mail.example.test", + 300001, 300100) == 1 && + BongoTlsRptStorePrepareNext(store, 259200, "Bongo test", + "mailto:postmaster@example.test", "mail.example.test", + 300001, 300100) == 0, + "late TLSRPT event was lost or prepared more than once"); + failed |= Check(!failed && + BongoTlsRptStoreNextDelivery(store, 300100, &delivery, + &next_not_before) == 1 && + delivery.endpoint_type == BONGO_TLSRPT_ENDPOINT_MAILTO && + delivery.attempts == 0 && delivery.expires_at == 386500 && + delivery.json && + strstr(delivery.json, "\"total-successful-session-count\":1"), + "prepared TLSRPT delivery is invalid"); + if (delivery.report_id[0]) + snprintf(first_report_id, sizeof(first_report_id), "%s", + delivery.report_id); + failed |= Check(!failed && BongoTlsRptStoreFinishDelivery( + store, delivery.id, BONGO_TLSRPT_DELIVERY_RETRY, 300200, + "temporary queue failure"), "cannot defer TLSRPT delivery"); + BongoTlsRptDeliveryFree(&delivery); + failed |= Check(!failed && + BongoTlsRptStoreNextDelivery(store, 300100, &delivery, + &next_not_before) == 1 && + strcmp(delivery.report_id, first_report_id) != 0 && + BongoTlsRptStoreFinishDelivery(store, delivery.id, + BONGO_TLSRPT_DELIVERY_SUCCESS, 0, ""), + "late TLSRPT event did not produce an independent report"); + BongoTlsRptDeliveryFree(&delivery); + failed |= Check(!failed && + BongoTlsRptStoreNextDelivery(store, 300200, &delivery, + &next_not_before) == 1 && + delivery.attempts == 1 && + strcmp(delivery.report_id, first_report_id) == 0 && + BongoTlsRptStoreFinishDelivery(store, delivery.id, + BONGO_TLSRPT_DELIVERY_SUCCESS, 0, ""), + "TLSRPT retry state was not persisted"); + BongoTlsRptDeliveryFree(&delivery); + failed |= Check(!failed && + BongoTlsRptStoreNextDelivery(store, 300200, &delivery, + &next_not_before) == 0 && + next_not_before == 0 && + BongoTlsRptStoreCleanupExpired(store, 400000), + "completed TLSRPT reports were not retired"); BongoTlsRptStoreClose(&store); unlink(database); snprintf(sidecar, sizeof(sidecar), "%s-wal", database); diff --git a/src/libs/tlsrpt/tlsrpt.c b/src/libs/tlsrpt/tlsrpt.c index cea6638..4def7ac 100644 --- a/src/libs/tlsrpt/tlsrpt.c +++ b/src/libs/tlsrpt/tlsrpt.c @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -34,6 +35,7 @@ struct BongoTlsRptStore { }; static const char StoreSchema[] = + "PRAGMA foreign_keys=ON;" "PRAGMA journal_mode=WAL;" "PRAGMA synchronous=FULL;" "CREATE TABLE IF NOT EXISTS tlsrpt_events (" @@ -59,7 +61,30 @@ static const char StoreSchema[] = " receiving_ip,additional_information,failure_reason)" ") WITHOUT ROWID;" "CREATE INDEX IF NOT EXISTS tlsrpt_events_report " - "ON tlsrpt_events(report_domain,day_start);"; + "ON tlsrpt_events(report_domain,day_start);" + "CREATE TABLE IF NOT EXISTS tlsrpt_reports (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " report_domain TEXT NOT NULL," + " day_start INTEGER NOT NULL," + " reporting_policy TEXT NOT NULL," + " report_id TEXT NOT NULL UNIQUE," + " json TEXT NOT NULL," + " created_at INTEGER NOT NULL," + " expires_at INTEGER NOT NULL" + ");" + "CREATE TABLE IF NOT EXISTS tlsrpt_deliveries (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " report INTEGER NOT NULL REFERENCES tlsrpt_reports(id) ON DELETE CASCADE," + " endpoint_type INTEGER NOT NULL," + " endpoint_uri TEXT NOT NULL," + " state INTEGER NOT NULL DEFAULT 0," + " attempts INTEGER NOT NULL DEFAULT 0," + " next_attempt INTEGER NOT NULL," + " last_error TEXT NOT NULL DEFAULT ''," + " UNIQUE(report,endpoint_uri)" + ");" + "CREATE INDEX IF NOT EXISTS tlsrpt_deliveries_due " + "ON tlsrpt_deliveries(state,next_attempt);"; static char * Trim(char *value) @@ -98,8 +123,8 @@ ParseEndpoint(char *value, BongoTlsRptEndpoint *endpoint) if (!ValidUriText(value)) return 0; if (strncmp(value, "mailto:", 7) == 0) { /* RFC 8460 requires URI delimiters in an address to be encoded. */ - if (!value[7] || !strchr(value + 7, '@') || strchr(value + 7, '!') || - strchr(value + 7, ',') || strchr(value + 7, ';')) return 0; + if (!value[7] || !strchr(value + 7, '@') || strchr(value + 7, ',') || + strchr(value + 7, ';')) return 0; endpoint->type = BONGO_TLSRPT_ENDPOINT_MAILTO; } else if (strncmp(value, "https://", 8) == 0) { authority = value + 8; @@ -635,3 +660,292 @@ BongoTlsRptStoreDeleteReport(BongoTlsRptStore *store, sqlite3_finalize(statement); return result; } + +static int +BeginImmediate(BongoTlsRptStore *store) +{ + return sqlite3_exec(store->database, "BEGIN IMMEDIATE", NULL, NULL, + NULL) == SQLITE_OK; +} + +static int +Commit(BongoTlsRptStore *store) +{ + return sqlite3_exec(store->database, "COMMIT", NULL, NULL, NULL) == + SQLITE_OK; +} + +static void +Rollback(BongoTlsRptStore *store) +{ + (void)sqlite3_exec(store->database, "ROLLBACK", NULL, NULL, NULL); +} + +static int +CopyColumn(sqlite3_stmt *statement, int column, char *output, size_t size) +{ + const unsigned char *value = sqlite3_column_text(statement, column); + size_t length; + + if (!value || !output || size == 0U) return 0; + length = strlen((const char *)value); + if (length >= size) return 0; + memcpy(output, value, length + 1U); + return 1; +} + +int +BongoTlsRptStorePrepareNext(BongoTlsRptStore *store, + int64_t before_day_start, + const char *organization_name, + const char *contact_info, + const char *submitter, + int64_t now, + int64_t initial_not_before) +{ + static const char Select[] = + "SELECT report_domain,day_start,reporting_policy " + "FROM tlsrpt_events WHERE day_start INT64_MAX - 86400 || + !ValidText(organization_name, 1024, 0) || !*organization_name || + !ValidText(contact_info, BONGO_TLSRPT_URI_SIZE, 0) || !*contact_info || + !ValidText(submitter, BONGO_TLSRPT_DOMAIN_SIZE, 0) || !*submitter || + !BeginImmediate(store)) return -1; + if (sqlite3_prepare_v2(store->database, Select, -1, &statement, NULL) != + SQLITE_OK) goto done; + sqlite3_bind_int64(statement, 1, before_day_start); + { + int step = sqlite3_step(statement); + if (step != SQLITE_ROW) { + if (step == SQLITE_DONE) result = 0; + goto done; + } + } + if (!CopyColumn(statement, 0, report_domain, sizeof(report_domain)) || + !CopyColumn(statement, 2, reporting_policy, + sizeof(reporting_policy))) goto done; + day_start = sqlite3_column_int64(statement, 1); + sqlite3_finalize(statement); + statement = NULL; + if (!BongoTlsRptParseRecord(reporting_policy, &policy)) goto done; + + if (sqlite3_prepare_v2(store->database, InsertReport, -1, &statement, + NULL) != SQLITE_OK) goto done; + sqlite3_bind_text(statement, 1, report_domain, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 2, day_start); + sqlite3_bind_text(statement, 3, reporting_policy, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 4, now); + /* RFC 8460 permits retries for 24 hours after the initial attempt. */ + sqlite3_bind_int64(statement, 5, initial_not_before + 86400); + if (sqlite3_step(statement) != SQLITE_DONE) goto done; + report = sqlite3_last_insert_rowid(store->database); + sqlite3_finalize(statement); + statement = NULL; + if (snprintf(report_id, sizeof(report_id), "tlsrpt-%" PRIi64 + "-%" PRIi64 "@%s", day_start, report, submitter) >= + (int)sizeof(report_id) || + !BongoTlsRptStoreBuildReport(store, report_domain, day_start, + reporting_policy, organization_name, + contact_info, report_id, &json)) goto done; + + if (sqlite3_prepare_v2(store->database, UpdateReport, -1, &statement, + NULL) != SQLITE_OK) goto done; + sqlite3_bind_text(statement, 1, report_id, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(statement, 2, json, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 3, report); + if (sqlite3_step(statement) != SQLITE_DONE) goto done; + sqlite3_finalize(statement); + statement = NULL; + + for (index = 0; index < policy.endpoint_count; index++) { + if (sqlite3_prepare_v2(store->database, InsertDelivery, -1, + &statement, NULL) != SQLITE_OK) goto done; + sqlite3_bind_int64(statement, 1, report); + sqlite3_bind_int(statement, 2, policy.endpoints[index].type); + sqlite3_bind_text(statement, 3, policy.endpoints[index].uri, -1, + SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 4, initial_not_before); + if (sqlite3_step(statement) != SQLITE_DONE) goto done; + sqlite3_finalize(statement); + statement = NULL; + } + if (!BongoTlsRptStoreDeleteReport(store, report_domain, day_start, + reporting_policy) || !Commit(store)) + goto done; + result = 1; + +done: + sqlite3_finalize(statement); + if (json) MemFree(json); + if (result != 1) { + if (result == 0 && Commit(store)) return 0; + Rollback(store); + return -1; + } + return 1; +} + +void +BongoTlsRptDeliveryFree(BongoTlsRptDelivery *delivery) +{ + if (!delivery) return; + if (delivery->json) MemFree(delivery->json); + memset(delivery, 0, sizeof(*delivery)); +} + +int +BongoTlsRptStoreNextDelivery(BongoTlsRptStore *store, int64_t now, + BongoTlsRptDelivery *delivery, + int64_t *next_not_before) +{ + static const char Due[] = + "SELECT d.id,d.endpoint_type,d.attempts,r.day_start,r.created_at," + "r.expires_at,r.report_domain,r.report_id,d.endpoint_uri,r.json " + "FROM tlsrpt_deliveries d JOIN tlsrpt_reports r ON r.id=d.report " + "WHERE d.state=0 AND d.next_attempt<=? AND r.expires_at>? " + "ORDER BY d.next_attempt,d.id LIMIT 1"; + static const char Next[] = + "SELECT min(d.next_attempt) FROM tlsrpt_deliveries d " + "JOIN tlsrpt_reports r ON r.id=d.report " + "WHERE d.state=0 AND r.expires_at>?"; + sqlite3_stmt *statement = NULL; + const unsigned char *json; + int step; + int result = -1; + + if (!store || !delivery || !next_not_before || now < 0) return -1; + memset(delivery, 0, sizeof(*delivery)); + *next_not_before = 0; + if (sqlite3_prepare_v2(store->database, Due, -1, &statement, NULL) != + SQLITE_OK) goto done; + sqlite3_bind_int64(statement, 1, now); + sqlite3_bind_int64(statement, 2, now); + step = sqlite3_step(statement); + if (step == SQLITE_ROW) { + delivery->id = sqlite3_column_int64(statement, 0); + delivery->endpoint_type = sqlite3_column_int(statement, 1); + delivery->attempts = sqlite3_column_int(statement, 2); + delivery->day_start = sqlite3_column_int64(statement, 3); + delivery->created_at = sqlite3_column_int64(statement, 4); + delivery->expires_at = sqlite3_column_int64(statement, 5); + json = sqlite3_column_text(statement, 9); + if (!CopyColumn(statement, 6, delivery->report_domain, + sizeof(delivery->report_domain)) || + !CopyColumn(statement, 7, delivery->report_id, + sizeof(delivery->report_id)) || + !CopyColumn(statement, 8, delivery->endpoint_uri, + sizeof(delivery->endpoint_uri)) || !json || + !(delivery->json = MemStrdup((const char *)json))) goto done; + result = 1; + goto done; + } + sqlite3_finalize(statement); + statement = NULL; + if (step != SQLITE_DONE || + sqlite3_prepare_v2(store->database, Next, -1, &statement, NULL) != + SQLITE_OK) goto done; + sqlite3_bind_int64(statement, 1, now); + step = sqlite3_step(statement); + if (step != SQLITE_ROW) goto done; + if (sqlite3_column_type(statement, 0) != SQLITE_NULL) + *next_not_before = sqlite3_column_int64(statement, 0); + result = 0; + +done: + sqlite3_finalize(statement); + if (result < 0) BongoTlsRptDeliveryFree(delivery); + return result; +} + +int +BongoTlsRptStoreFinishDelivery(BongoTlsRptStore *store, + int64_t delivery_id, + BongoTlsRptDeliveryResult result, + int64_t next_attempt, + const char *error) +{ + static const char Update[] = + "UPDATE tlsrpt_deliveries SET state=?,attempts=attempts+1," + "next_attempt=?,last_error=? WHERE id=?"; + static const char Finish[] = + "DELETE FROM tlsrpt_reports WHERE id=(SELECT report FROM " + "tlsrpt_deliveries WHERE id=?) AND NOT EXISTS (SELECT 1 FROM " + "tlsrpt_deliveries WHERE report=(SELECT report FROM " + "tlsrpt_deliveries WHERE id=?) AND state=0)"; + sqlite3_stmt *statement = NULL; + int changed; + int state; + int ok = 0; + + if (!store || delivery_id <= 0 || result < BONGO_TLSRPT_DELIVERY_RETRY || + result > BONGO_TLSRPT_DELIVERY_PERMANENT_FAILURE || + next_attempt < 0 || !error || strlen(error) > 4096 || + !BeginImmediate(store)) return 0; + state = result == BONGO_TLSRPT_DELIVERY_RETRY ? 0 : (int)result; + if (sqlite3_prepare_v2(store->database, Update, -1, &statement, NULL) != + SQLITE_OK) goto done; + sqlite3_bind_int(statement, 1, state); + sqlite3_bind_int64(statement, 2, + state == 0 ? next_attempt : 0); + sqlite3_bind_text(statement, 3, error, -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(statement, 4, delivery_id); + if (sqlite3_step(statement) != SQLITE_DONE) goto done; + changed = sqlite3_changes(store->database); + sqlite3_finalize(statement); + statement = NULL; + if (changed != 1) goto done; + if (state != 0) { + if (sqlite3_prepare_v2(store->database, Finish, -1, &statement, + NULL) != SQLITE_OK) goto done; + sqlite3_bind_int64(statement, 1, delivery_id); + sqlite3_bind_int64(statement, 2, delivery_id); + if (sqlite3_step(statement) != SQLITE_DONE) goto done; + } + if (!Commit(store)) goto done; + ok = 1; + +done: + sqlite3_finalize(statement); + if (!ok) Rollback(store); + return ok; +} + +int +BongoTlsRptStoreCleanupExpired(BongoTlsRptStore *store, int64_t now) +{ + sqlite3_stmt *statement = NULL; + int result; + + if (!store || now < 0 || + sqlite3_prepare_v2(store->database, + "DELETE FROM tlsrpt_reports WHERE expires_at<=?", -1, + &statement, NULL) != SQLITE_OK) return 0; + sqlite3_bind_int64(statement, 1, now); + result = sqlite3_step(statement) == SQLITE_DONE; + sqlite3_finalize(statement); + return result; +}