]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] Add dynamic block API to fuzzy storage worker
authorVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 2 Apr 2026 09:30:43 +0000 (10:30 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Thu, 2 Apr 2026 09:30:43 +0000 (10:30 +0100)
Adds worker:block_fuzzy_client(addr, prefix_len[, expire_ts[, reason]])
Lua method that inserts a CIDR network block into a per-worker btrie
(dynamic_blocked_nets). Checked in rspamd_fuzzy_check_client() after
the static blocked_ips map.

expire_ts is an absolute monotonic timestamp (0 = permanent). Callers
compute get_time() once and pass it to a batch of block calls — C never
calls get_time() itself. Re-inserting an existing prefix updates the
expire_ts and reason in-place (idempotent).

Each fuzzy worker independently applies bans via Redis polling; no new
IPC commands or control socket extensions are needed.

src/fuzzy_storage.c
src/libserver/fuzzy_storage_internal.h
src/libserver/fuzzy_storage_ratelimit.c

index 285bce8cae282fce2c9829e1d0aaa7fae1f54e37..fa2cbc3c891e4cd0de4ee3fa55482768c83fc284 100644 (file)
@@ -38,6 +38,7 @@
 #include "contrib/uthash/utlist.h"
 #include "lua/lua_common.h"
 #include "unix-std.h"
+#include "libutil/radix.h"
 
 #include <math.h>
 
@@ -2700,6 +2701,44 @@ lua_fuzzy_add_blacklist_handler(lua_State *L)
        return 0;
 }
 
+/*
+ * worker:block_fuzzy_client(addr_string, prefix_len[, expire_ts[, reason]])
+ *
+ * Dynamically block an IP address or CIDR network in this worker.
+ * expire_ts is an absolute monotonic timestamp (rspamd_util.get_time() + ttl).
+ * Pass nil or 0 for a permanent block (until worker restart).
+ * Lua callers can call get_time() once and pass the result to a batch of
+ * block calls — the C layer never calls get_time() itself.
+ *
+ * Returns: true on success; false, errmsg on parse failure.
+ */
+static int
+lua_fuzzy_block_client(lua_State *L)
+{
+       struct rspamd_worker **pwrk = (struct rspamd_worker **)
+               rspamd_lua_check_udata(L, 1, rspamd_worker_classname);
+
+       if (!pwrk) {
+               return luaL_error(L, "invalid self: worker expected");
+       }
+
+       struct rspamd_fuzzy_storage_ctx *ctx = (struct rspamd_fuzzy_storage_ctx *) (*pwrk)->ctx;
+
+       const char *addr_str = luaL_checkstring(L, 2);
+       unsigned int prefix_len = (unsigned int) luaL_checkinteger(L, 3);
+       double expire_ts = luaL_optnumber(L, 4, 0.0);
+       const char *reason = luaL_optstring(L, 5, "lua");
+
+       if (rspamd_fuzzy_block_addr(ctx, addr_str, prefix_len, expire_ts, reason)) {
+               lua_pushboolean(L, TRUE);
+               return 1;
+       }
+
+       lua_pushboolean(L, FALSE);
+       lua_pushfstring(L, "failed to parse address: %s", addr_str);
+       return 2;
+}
+
 gpointer
 init_fuzzy(struct rspamd_config *cfg)
 {
@@ -3251,6 +3290,7 @@ start_fuzzy(struct rspamd_worker *worker)
        ctx->ratelimit_buckets = rspamd_lru_hash_new_full(ctx->max_buckets,
                                                                                                          NULL, fuzzy_rl_bucket_free,
                                                                                                          rspamd_inet_address_hash, rspamd_inet_address_equal);
+       ctx->dynamic_blocked_nets = radix_create_compressed("dynamic_blocked_nets");
 
        rspamd_fuzzy_maybe_load_ratelimits(ctx);
 
@@ -3304,6 +3344,11 @@ start_fuzzy(struct rspamd_worker *worker)
                .func = lua_fuzzy_add_blacklist_handler,
        };
        rspamd_lua_add_metamethod(ctx->cfg->lua_state, rspamd_worker_classname, &fuzzy_lua_reg);
+       fuzzy_lua_reg = (luaL_Reg) {
+               .name = "block_fuzzy_client",
+               .func = lua_fuzzy_block_client,
+       };
+       rspamd_lua_add_metamethod(ctx->cfg->lua_state, rspamd_worker_classname, &fuzzy_lua_reg);
 
        rspamd_lua_run_postloads(ctx->cfg->lua_state, ctx->cfg, ctx->event_loop,
                                                         worker);
@@ -3352,6 +3397,13 @@ start_fuzzy(struct rspamd_worker *worker)
                rspamd_lru_hash_destroy(ctx->ratelimit_buckets);
        }
 
+       if (ctx->dynamic_blocked_nets) {
+               /* Ban structs are pool-allocated inside the radix tree's pool,
+                * so radix_destroy_compressed frees them all at once */
+               radix_destroy_compressed(ctx->dynamic_blocked_nets);
+               ctx->dynamic_blocked_nets = NULL;
+       }
+
        struct rspamd_lua_fuzzy_script *cur, *tmp;
 
        LL_FOREACH_SAFE(ctx->lua_pre_handlers, cur, tmp)
index f0c1b274bfd004aca42ed07abce5086124a65f0b..3a8410183f96dade804f8bea028eb54c3b2f9534 100644 (file)
@@ -32,6 +32,7 @@ struct map_cb_data;
 struct rspamd_rcl_section;
 struct rspamd_rcl_struct_parser;
 struct rspamd_control_command;
+typedef struct radix_tree_compressed radix_compressed_t;
 
 struct rspamd_main;
 struct rspamd_worker;
@@ -80,6 +81,11 @@ struct rspamd_leaky_bucket_elt {
        double cur;
 };
 
+struct rspamd_fuzzy_dynamic_ban {
+       double expire_ts; /* monotonic clock; 0.0 = never expires */
+       char reason[64];
+};
+
 static inline int64_t
 fuzzy_kp_hash(const unsigned char *p)
 {
@@ -146,6 +152,7 @@ struct rspamd_fuzzy_storage_ctx {
        struct rspamd_radix_map_helper *update_ips;
        struct rspamd_hash_map_helper *update_keys;
        struct rspamd_radix_map_helper *blocked_ips;
+       radix_compressed_t *dynamic_blocked_nets;
        struct rspamd_radix_map_helper *ratelimit_whitelist;
        struct rspamd_radix_map_helper *delay_whitelist;
 
@@ -283,6 +290,12 @@ gboolean fuzzy_parse_keypair(rspamd_mempool_t *pool, const ucl_object_t *obj,
 /* Ratelimit */
 void fuzzy_rl_bucket_free(gpointer p);
 
+gboolean rspamd_fuzzy_block_addr(struct rspamd_fuzzy_storage_ctx *ctx,
+                                                                const char *addr_str,
+                                                                unsigned int prefix_len,
+                                                                double expire_ts,
+                                                                const char *reason);
+
 enum rspamd_ratelimit_check_result rspamd_fuzzy_check_ratelimit_bucket(
        struct rspamd_fuzzy_storage_ctx *ctx,
        rspamd_inet_addr_t *addr,
index 1f7c1e8fa8dcf27c49ffc811fa9f8eb6456508fa..0f4f496a24825f31b63d4f0e13e39f8bd89300dd 100644 (file)
@@ -25,6 +25,8 @@
 #include "rspamd_control.h"
 #include "lua/lua_common.h"
 #include "contrib/uthash/utlist.h"
+#include "libutil/radix.h"
+#include "libutil/addr.h"
 
 #include <errno.h>
 #include <math.h>
@@ -406,6 +408,108 @@ rspamd_fuzzy_check_client(struct rspamd_fuzzy_storage_ctx *ctx,
                }
        }
 
+       if (ctx->dynamic_blocked_nets != NULL) {
+               uintptr_t val = radix_find_compressed_addr(ctx->dynamic_blocked_nets, addr);
+
+               if (val != RADIX_NO_VALUE) {
+                       struct rspamd_fuzzy_dynamic_ban *ban = (struct rspamd_fuzzy_dynamic_ban *) val;
+                       double now = rspamd_get_ticks(FALSE);
+
+                       if (ban->expire_ts == 0.0 || ban->expire_ts > now) {
+                               rspamd_fuzzy_maybe_call_blacklisted(ctx, addr, "dynamic_blocked");
+                               return FALSE;
+                       }
+               }
+       }
+
+       return TRUE;
+}
+
+gboolean
+rspamd_fuzzy_block_addr(struct rspamd_fuzzy_storage_ctx *ctx,
+                                               const char *addr_str,
+                                               unsigned int prefix_len,
+                                               double expire_ts,
+                                               const char *reason)
+{
+       rspamd_inet_addr_t *addr;
+
+       if (!rspamd_parse_inet_address(&addr, addr_str, strlen(addr_str),
+                                                                  RSPAMD_INET_ADDRESS_PARSE_NO_UNIX | RSPAMD_INET_ADDRESS_PARSE_NO_PORT)) {
+               msg_err("block_fuzzy_client: cannot parse address: %s", addr_str);
+               return FALSE;
+       }
+
+       /* Apply network mask to zero out host bits */
+       rspamd_inet_address_apply_mask(addr, prefix_len);
+
+       /* Allocate ban struct from the tree's memory pool so it is freed automatically
+        * when the tree is destroyed at shutdown */
+       struct rspamd_fuzzy_dynamic_ban *new_ban =
+               rspamd_mempool_alloc0(radix_get_pool(ctx->dynamic_blocked_nets),
+                                                         sizeof(*new_ban));
+       new_ban->expire_ts = expire_ts;
+
+       if (reason != NULL) {
+               rspamd_strlcpy(new_ban->reason, reason, sizeof(new_ban->reason));
+       }
+
+       /* Build the 16-byte IPv6-mapped key and compute masklen */
+       const unsigned char *raw;
+       unsigned int klen = 0;
+       unsigned char key[16];
+       gsize masklen;
+
+       raw = rspamd_inet_address_get_hash_key(addr, &klen);
+
+       if (raw == NULL || klen == 0) {
+               rspamd_inet_address_free(addr);
+               return FALSE;
+       }
+
+       if (klen == 4) {
+               /* IPv4: map to ::ffff:a.b.c.d */
+               memset(key, 0, 10);
+               key[10] = 0xffu;
+               key[11] = 0xffu;
+               memcpy(key + 12, raw, 4);
+               if (prefix_len > 32) {
+                       prefix_len = 32;
+               }
+               /* In the 128-bit IPv6-mapped form, the prefix occupies prefix_len+96 bits */
+               masklen = 32 - prefix_len; /* = 128 - (prefix_len + 96) */
+       }
+       else {
+               /* IPv6 */
+               memcpy(key, raw, 16);
+               if (prefix_len > 128) {
+                       prefix_len = 128;
+               }
+               masklen = 128 - prefix_len;
+       }
+
+       uintptr_t old = radix_insert_compressed(ctx->dynamic_blocked_nets,
+                                                                                       key, sizeof(key), masklen,
+                                                                                       (uintptr_t) new_ban);
+
+       if (old != RADIX_NO_VALUE) {
+               /* Duplicate prefix: btrie was NOT updated (it returned the existing value).
+                * Update the existing ban struct in-place so the same memory location now
+                * reflects the new TTL and reason. new_ban is pool-allocated so it leaks
+                * a tiny struct, but that is acceptable. */
+               struct rspamd_fuzzy_dynamic_ban *existing = (struct rspamd_fuzzy_dynamic_ban *) old;
+               existing->expire_ts = expire_ts;
+
+               if (reason != NULL) {
+                       rspamd_strlcpy(existing->reason, reason, sizeof(existing->reason));
+               }
+       }
+
+       msg_info("dynamic block added for %s/%ud, expire_ts=%.0f, reason=%s",
+                        addr_str, prefix_len, expire_ts, reason ? reason : "");
+
+       rspamd_inet_address_free(addr);
+
        return TRUE;
 }