Commit Graph
2938 Commits
Author SHA1 Message Date
Wez Furlong 0e339a2b9e egress_source: add integration tests and fixup source_selection_rate
Add test coverage for source_selection_rate and tidy up how we reflect
being throttled due to source_selection_rate; in the first cut
we made it look like the ready queue was full, but now we're a
bit more deliberate:

* If any of the candidate queues were full, we report that the
  ready queue was full so that the message can be retried again
  "soon".
* If we hit a source_selection_rate throttle, we use the throttle
  delay to adjust the min delay period and if we run out of candidate
  sources, we'll have the same sort of return value as we would
  for a suspension with a definite duration; the message will
  log that transient failure in the same way with the same sort
  of retry schedule.  I've revised the wording in that case to be
  "no sources for SITE pool=`POOL` are eligible for selection at this time"
  (the important part being `at this time`) to suggest at the
  transient nature of the unavailability.
* If we exhaust all sources for other reasons, we'll treat this
  as the NoSources case. In practice, this should mean that the
  source has zero weight. We'll now report "no sources available for SITE
  pool=`POOL`" in this case instead of the more precise but wordy
  "no non-zero-weighted sources available for SITE" that we used
  prior to this commit.
2025-03-11 08:37:09 -07:00
Wez Furlong df02f02ae2 egress_source: fixup for deadline changes 2025-03-11 06:56:47 -07:00
Wez Furlong 50c6cafeb3 fixup for memory policy changes 2025-03-11 06:56:47 -07:00
Wez Furlong b10c9a8bf7 add source_selection_rate option
This is useful to effect IP warmup.

Needs an integration test.
2025-03-11 06:56:46 -07:00
Wez Furlong b150277cd5 egress_source: adopt reservations for insertion
This switches to the two-phase insert, we first reserve,
the redeem the reservation to perform the insert.

This has no immediate benefit, but will allow the use
of a just-in-time check against eg: a warm-up throttle
for the source.
2025-03-11 06:56:46 -07:00
Wez Furlong d99f29ebbb egress_source: try other sources if the selected entry is full
Previously, we'd pick a source whether it had room for the new
message or not, then generate a TransientFailure when we subsequent
figure out that it is full.

This commit will try to deliver through one of the other possible
sources instead of delaying the message.
2025-03-11 06:56:46 -07:00
Wez Furlong e1252b1c3a refactor: egress_source: remove select_source method
fold it into the new higher level method
2025-03-11 06:56:46 -07:00
Wez Furlong 48be47f682 egress_source: shift insertion responsibility to selector
This is a small refactor to perform the actual message insertion
as part of the selection logic. It will enable more advanced
actions in the future, and it simplifies the logic in the
queue.rs side of things a little.
2025-03-11 06:56:45 -07:00
Wez Furlong 663f994d20 refactor: rename RoundRobin -> SourceSelector
The struct that performs our weighted round robin will be expanded
in a later commit, this adjusts its name to better reflect its
purpose, rather than its current implementation.
2025-03-11 06:56:45 -07:00
Wez Furlong 32e520eae4 ready_queue: introduce FifoReservation helper
This isn't used anywhere yet, but will facilitate a more powerful
source algorithm.
2025-03-11 06:56:45 -07:00
Wez Furlong f1f5d27591 sources.lua: surface socks5_proxy_source_address issues better
This field only accepts IP addresses; it is an error to use
a DNS name with it.

The sources helper was swallowing the helpful part of the context
about the issue with that field.

This commit ensures that we do surface it.
2025-03-11 06:55:44 -07:00
Wez Furlong 0d936fbecf server-common: adjust wording when policy file has an error
If there is a runtime error when first loading the policy,
we would claim that we failed to parse it, which is misleading.

Adjust the wording to "Error evaluating policy" rather than
"Failed to parse policy".
2025-03-10 14:07:35 -07:00
Wez Furlong f396a0f7fe kumo-dmarc: refactor: extract most types
no functional changes
2025-03-10 09:54:49 -07:00
Wez Furlong 03ca5f8a4b cargo update 2025-03-07 14:01:19 -07:00
Wez Furlong 04e48ee65e deps: update ring but pin to 0.17.12
We can't use the very latest version of this right now because
there are some build changes in that version that break on
amazonlinux:2.

I think 0.17.12 will probably be ok, but I stand ready to figure
out next steps if this is also having trouble on amazonlinux:2

refs: https://github.com/briansmith/ring/issues/2462
closes: https://github.com/KumoCorp/kumomta/pull/353
2025-03-07 13:48:09 -07:00
Wez Furlong 501d1f26e5 dkim: support verifying multiple keys
While auditing Answer::as_txt usage as a follow up from the recent
SPF fix, I noticed a TODO in the dkim code (which we forked from
another implementation) to support processing multiple TXT
records.

This commit implements the necessary tweaks to extract multiple
signatures and attempt to verify them against the incoming message.
2025-03-07 09:26:55 -07:00
Wez Furlong 6cf315c37d dkim: extract public key parsing guts
No overall functional change, this is just moving the guts
around a bit to support validating from multiple signatures
in a later commit.
2025-03-07 09:05:02 -07:00
Wez Furlong f23875b1a1 shaping.lua: mostly switch prints to kumo.log_XXX
Keeping the validation stuff as prints for the time being,
as I think that looks nicer in the context of a standalone
validation mode.
2025-03-07 08:06:35 -07:00
Wez Furlong 07b2d166b5 spf: don't treat TXT-but-no-SPF as an SPF parsing perm fail
I introduced this issue in the previous commit; fix it!
2025-03-07 07:24:04 -07:00
Wez Furlong 7953a7b0f2 fix: spf didn't parse records when TXT had non-spf records
The issue here was essentially a data fidelity issue around
TXT record representation.

A DNS TXT record can be composed from multiple strings, and a domain can
return multiple TXT records, so there is some nesting.

The SPF RFC says:

```
3.3.  Multiple Strings in a Single DNS Record

   As defined in [RFC1035], Sections 3.3 and 3.3.14, a single text DNS
   record can be composed of more than one string.  If a published
   record contains multiple character-strings, then the record MUST be
   treated as if those strings are concatenated together without adding
   spaces.  For example:

      IN TXT "v=spf1 .... first" "second string..."

   is equivalent to:

      IN TXT "v=spf1 .... firstsecond string..."

   TXT records containing multiple strings are useful in constructing
   records that would exceed the 255-octet maximum length of a
   character-string within a single TXT record.
```

so the SPF logic was dutifully joining records together around the
empty string.

Howerver, if you look at `dig yahoo.com txt` you'll see a bunch
of non-SPF records:

```
yahoo.com.              1800    IN      TXT     "google-site-verification=Z3-Vh6zqUMgybVH4wQl1GxKSKN7JE13kyCyeZ3TZZ-I"
yahoo.com.              1800    IN      TXT     "v=spf1 redirect=_spf.mail.yahoo.com"
yahoo.com.              1800    IN      TXT     "Zoom=13284637"
yahoo.com.              1800    IN      TXT     "edb3bff2c0d64622a9b2250438277a59"
```

these were getting joined together and producing a bogus input.

Obviously we should not join the results from the txt lookup
together like that, but then why would the RFC make a point
of talking about joining stuff together, and where should
that logic live?

Our `Answer::as_txt` implementation was doing some joining
of its own and it turned out that it was concatenating across
the outer layer of the aforementioned TXT record nesting.

This commit fixes that up and adds some test coverage.
2025-03-07 07:06:10 -07:00
Wez Furlong 298d470527 spf: fix potential panic when spf TXT record contains a trailing space 2025-03-07 07:01:05 -07:00
Wez Furlong 686778a5d0 deps: update redis-rs to 0.29 2025-03-07 05:25:45 -07:00
Wez Furlong 0fe856516c add kumo.log_(error|info|warn|debug) functions
These log their arguments to the diagnostic log.
2025-03-06 17:15:15 -07:00
Wez Furlong 70d9ec394a config: introduce declare_event! macro
There should be no functional change here; this commit refactors
how we pre-declare CallbackSignatures with lua. The rationale is:

* It is important to correct declare single vs. multiple implementation
  event handler types before any lua code is run; we've had a couple
  of issues in the past where some part of this pre-registration
  was messed up.
* Adding a new signature requires declaring it in a global, and
  remembering to add its registration to the right place
* Declaring signatures is a bit boilerplatey and makes it hard
  grok the purpose of the event handler arguments at a glance

The declare_event! macro defined in this commit makes it a bit
more readable to declare these event types and automatically
wires up the registration to the correct spot, improving the
ergonomics significantly.

A downside of this additional layer of macro stuff that it
requires increasing the rustc recursion limit.
2025-03-06 06:35:38 -07:00
Wez Furlong 85b922d40b add kumo.set_lruttl_cache_capacity function
This allows configuring the capacity limit for internal caches.
2025-03-05 08:50:11 -07:00
Wez Furlong fd8f299d72 lruttl: refactor clear method and make more robust
Let's have it abort any pending waiters: we only call clear
when memory is low so we want all those things to unwind
in a timely fashion without getting blocked.
2025-03-05 08:12:12 -07:00
Wez Furlong f1d52034b9 lruttl: refactor: extract metrics to separate module 2025-03-05 08:11:54 -07:00
Wez Furlong bcd0e3746c lruttl: add predefined cache registry
This commit adds a declare_cache! macro that serves a couple of
purposes:

* Make it slightly less boilerplatey to define a cache
* Register them explicitly as a list of predefined caches, so that
  we can raise an error if lua code would conflict via memoize
* Enable a future change that allows reconfiguring the cache parameters
  for those predefined caches

Note: if you are using clang as a linker via RUSTFLAGS/.make-env,
the linkme crate that is employed for the registry may result
in linker errors. The solution that I have in my `.make-env` is
to add `-Clink-arg=-Wl,-z,nostart-stop-gc` to RUSTFLAGS so that
its sections are not removed too soon in the build process:

```
; cat .make-env
ROCKSDB_LIB_DIR=/usr/lib/x86_64-linux-gnu
RUSTFLAGS=-C linker=clang -C link-arg=-fuse-ld=lld --cfg tokio_unstable -Clink-arg=-Wl,-z,nostart-stop-gc
```
2025-03-05 07:54:21 -07:00
Wez Furlong 5c154417c7 shaping.lua: fix missing string.format calls
closes: https://github.com/KumoCorp/kumomta/issues/351
2025-03-04 16:24:58 -07:00
Wez Furlong b9fe715c5e changelog: mention lruttl cache change
This is really just so that something shows up there, rather than
an exhaustive description of what those changes are.
2025-03-04 16:23:17 -07:00
Wez Furlong b2887cb7b8 data_processing_timeout: increase default to 5 minutes
I worry that 1 minute might be too short to be universally good,
particularly as a change in behavior from an earlier build: going from
no timeout to 1 minute could be super surprising and un-welcome.

Let's bump this up to 5 minutes which more closely matches the
default for DATA in the base SMTP RFC.
2025-03-04 16:23:16 -07:00
Wez Furlong c35838bc6d smtp_server: feed deadline through to queue insertion
After making spool stores respect the data processing deadline, in soak
testing it was interesting to see that the outliers were not actually
due to spooling, but instead due to latency spikes around delays
fetching data for the various lua hooks for queue and shaping helpers to
enforce things like provider level shaping.

This commit passes the optional deadline through to the queue insertion
logic, populating it only for the initial ingress and not for the
requeue or spool enumeration cases.

The application of conditional timeouts pushes the size of the compiled
Futures over a stack-friendly threshold which (thankfully) showed up
immediately in integration tests.  The remedy for those is to sprinkle
Box::pin around various futures to encourage them to live in the heap
instead of on the stack.

I'm a bit concerned about this, as the stack usage is workload
dependent: I'm not sure that I've covered off enough of these
to ensure that we won't see stack overflows in the future.

If you are running into a stack overflow please report it!
Note that you can mitigate a stack overflow by exporting
`RUST_MIN_STACK=33554432` into the environment of kumod to
set a larger default size.

This commit catches the timeout error and converts it into
a `451 4.4.5 data_processing_timeout exceeded (insert)` instead
of letting it bubble up as a 421 technical difficulties response
that would also have closed the connection.
2025-03-04 16:23:16 -07:00
Wez Furlong 054508321b lruttl: drop moka, roll our own cache on top of dashmap
I've been having trouble with moka's eviction policy leading
to some unwanted duplicate work, especially when the system
is under load and the latency is higher.

This commit swaps it out in favor of a relatively simple
implementation on top of dashmap; that gives us a decent
concurrent base and better respects the concurrency
limit and LRU around the contentious insertion case.

For LRU, since dashmap is a concurrent data structure, it
is ~impossible to use the classic doubly linked LRU approach
safely.

The strategy used here is a proabalistic sampling LRU using a technique
similar to that used in redis.

We use atomics to tag read and write entries with a monotonic counter.
Entries with smaller counter numbers are least-recent than entries with
a larger number.

During eviction we take a random sample of 10 entries from the cache
map, remove any that have expired due to TTL, and if we still need space
after that, we'll pick up to half of those to evict.  That guarantees
that we don't pick the most recent entry of the sample, and on aggregate
this should be a reasonable approximation of the "true" LRU.

In the background, every 30s, a maintenance task will remove expired
entries from the caches.  This is primarily to reduce memory utilization
when the cache is otherwise idle.  When a cache is hot, expiration is
performed as part of the eviction logic described above.
2025-03-04 16:23:16 -07:00
Wez Furlong 68ff551aa9 lruttl: tidy up api
Fewer weirdly named functions and awkward parameter types.
2025-03-04 16:23:15 -07:00
Wez Furlong 875b54732b egress_source: add thundering herd protection to source+pool cache 2025-03-04 16:23:15 -07:00
Wez Furlong fa1857be98 docs: add data_processing_timeout 2025-03-04 16:23:15 -07:00
Wez Furlong c95fd24d9e memoize/lruttl: improve robustness
This commit fixes up a few edge cases:

* The recent switch to moka for lruttl adopted an LFU eviction
  policy which could choose not to take a recently updated value
  in some situations, which is undesirable for expensive cache
  population functions. Explicitly configure it to use classic
  LRU for its eviction policy.
* The semaphore based thundering herd control in memoize had
  a very hard to pin down edge case where we could end up
  blocking for an extended period of time.  Switch to the
  `.get_or_try_insert_with_freshness_status` method that moka
  enabled in our lruttl module
* That didn't eliminate the edge case, but rather limited the
  scope of its negative effects. There is still an edge case,
  but now it results in serial duplicate calls to the cache
  population function, which is wasteful but not as egregious
  as the extended period of blocking.
  I've opened https://github.com/moka-rs/moka/discussions/502
  with moka to see what is going wrong there
* Adopt dashmap for the map of caches, to reduce hypothetical
  contention across multiple caches. This is a micro optimization.
* Improved tracing of lruttl and memoize
2025-03-04 16:23:14 -07:00
Wez Furlong c5014f9594 docs: add some rocksdb tuning options to the docs 2025-03-04 16:23:14 -07:00
Wez Furlong df8aa10ad1 docs: split define_spool docs into separate pages 2025-03-04 16:23:14 -07:00
Wez Furlong d24bf63572 ready_queue: switch manager map to dashmap
This reduces potential contention when multiple actors
are working on resolving different ready queues concurrently.
2025-03-04 16:23:13 -07:00
Wez Furlong 591e9f5380 spool: add optional deadline for store operations
Under high concurrency, a backlog of store operations can build
up, especially if rocksdb isn't tuned for the workload.

In order to work together with the data_processing_timeout of the smtp
server, we'd like to be able to cancel store operations that are past
the deadline, but the spool operations are not guaranteed to be cancel
safe because both the rocksdb and the local disk implementations can
ultimately call into spawn_blocking to perform the critical portion of
the work, and that is not cancel safe.

This means that we cannot simply apply timeout_at to the store
operation: while it may appear to the caller to have been cancelled at
the appropriate time, the actual work storing the spool may be in
progress and continue to store the message to the spool, which could
lead to us thinking that we are accountable for the message when we next
restart, even though we're presumably about to tell the sender that we
did not take the message.

To deal with this situation more safely, we pass the deadline through
to the underlying operation and have it internally decide whether
it should continue.

In practice, this means that the rocksdb store operation will apply
timeout_at to the semaphore operation it does if
`limit_concurrent_stores` has been configured fr that spool.  Otherwise
the deadline is ignored.

The implementation of this is imperfect in a number of ways. A more
robust solution would be to employ something like
<https://docs.rs/cancel-safe-futures/0.1.5/cancel_safe_futures/coop_cancel/index.html>,
but that is a more invasive change and one that is a bit difficult to
wrangle across the spool trait definition in its current form.

If `limit_concurrent_stores` is not in use, this commit doesn't
change the behavior of the spool store operation.
2025-03-04 16:23:13 -07:00
Wez Furlong 2bd7410ca6 config: fix cancellation safety issue with async lua calls
This commit addresses a tricky interaction with lua's garbage collection
and cancellation safety of async lua call handling.

In Rust, when a future is cancelled it is simply not called any more,
and any resources it owns will be released when it is dropped.

In mlua's Rust bindings, async calls are futures that are wrapped
up into state whose ownership is managed in the lua context
stack, which in turn has managed via its own garbage collection
process.

In KumoMTA, we implement a pool of Lua contexts which we keep
around for reuse.  Our logic will return our LuaConfig object
back to this pool automatically when it is dropped.

In a situation where tokio timeouts or select! are used with
a future that transitively contains a lua async call and that
call is cancelled, that can leave async locks/semaphores owned
in the un-collected objects in the lua stack.

For async locks/semaphores that were in the locked state, this
can leave them locked until the owning lua context is expired
from the pool, either due to age, capacity or use count being hit.

That's bad because it can block certain processing flows for 5 minutes
(the default `kumo.set_max_lua_context_age`).

What this commit does is switch away from automatically returning
the LuaConfig to the pool on Drop.  In that case the lua context
is destroy on Drop, immediately allowing destructors to release
any/all owned resources.

We do still want to benefit from the pool, so the bulk of
this commit is changing the consumers of load_config to
explicitly call `config.put()` to put the object back into
the pool, but only in the success case.

This does penalize flows that grab a config and then
`kumo.reject` as their final status. At the present time, the
majority of kumomta deployments are very much focused on (relatively)
trusted senders and an outbound use case, so this isn't a terrible
situation for the time being.
2025-03-04 16:23:13 -07:00
Wez Furlong d19034b6a5 queue: adopt dashmap for queue manager map
This is a lock free concurrent map, providing a micro optimization
in the queue resolution code path when concurrent actors are
resolving different queues.
2025-03-04 16:23:12 -07:00
Wez Furlong b56a84ab60 queue: make QueueManager::resolve cancel safe
Previously, this code implemented work deduplication through the
use of a simple Notify object to signal waiters after the primary
task had completed.

I'd like to apply a timeout to the resolve function but the simplistic
signalling mechanism will be prone to leaving pending waiters stalled
if the task that is responsible for filling out the slot is simply
never polled again once the timeout is reached--it doesn't behave
well in the cancellation case.

This commit switches to a more robust signalling mechanism that
uses a Semaphore to ensure that all waiters have a chance to wake
up and respond to a cancelled task on the same queue name.

While in here, I put in a negative cache so that in a persistent
error scenario, we're not just subjecting the queue of waiters
to re-attempt something that is likely to fail in a similar
time frame.
2025-03-04 16:23:12 -07:00
Wez Furlong 5248ac2171 smtp_server: add data_processing_timeout that defaults to 60s
This is an internal timeout that applies across the `process_data`
method, which encapsulates the span of time between receiving the
last part of the DATA payload and responding to it.

The intent here is that we will return a timeout 4xx response to
the injecting client if we take too long.

In most cases we can do this at the point that the deadline is
reached, but there are some cases where we cannot cancel the
ongoing work and instead will detect the timeout after the deadline
has been reached.

We will stop short of putting the message into the queue subsystem
in that case, and attempt to unwind the message from the spool
and emit appropriate log records.

The intent here is *not* to set a firm deadline on *responding* to DATA,
but rather to ensure that we don't assume responsibility for relaying
the message *if* the deadline was exceeded.  It is still possible for
processing to exceed the deadline.

This behavior is more valuable than it may seem at first glance because
a client that times out waiting for the server to respond has no way to
know if the send was successful, and if its strategy is to retry the
message AND the server accepted responsibility for it, then the result
will be a duplicate delivery each time the timeout is experienced.
2025-03-04 16:23:11 -07:00
Wez Furlong f06d7bb465 lua: add __len metamethod for serializable userdata
This is added for consistency with the __pairs and __index metamethods.
2025-03-04 16:23:11 -07:00
Ryan Bonnell eddc42b45c Add missing space in Markdown link to 'UserGuide' 2025-02-28 08:41:39 -08:00
Ryan Bonnell 4180e3a512 Fix accidental misspellings 2025-02-28 08:41:16 -08:00
Ryan Bonnell 04e52757a3 Re-wrap line breaks in comments for readability 2025-02-28 08:40:36 -08:00
Ryan Bonnell c91b978ea3 Fix misspelling of word 'parameter' 2025-02-28 08:40:07 -08:00