]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] lua_scanners: emit fail symbol on nil upstream
authorVsevolod Stakhov <vsevolod@rspamd.com>
Sat, 25 Apr 2026 18:44:16 +0000 (19:44 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Sat, 25 Apr 2026 18:44:16 +0000 (19:44 +0100)
After the upstream layer learnt to defer DNS resolution, every
scanner's :get_upstream_round_robin() may return nil while the host
is still pending (or when every backend has been marked dead). The
scanners then crashed in the scan callback on `upstream:get_addr()`,
which silently aborted the scan instead of surfacing the failure.

Add a shared lua_scanners/common.get_upstream_or_fail(task, rule,
maybe_part, reason) helper that selects an upstream and, on nil,
emits the configured *_FAIL symbol via yield_result with reason
"no upstream available (DNS pending or all dead)". Update every
scanner under lualib/lua_scanners/ to use the helper for the initial
selection, and to add an inline yield_result+return guard around the
retry-on-error sites that pick a different upstream.

Cloudmark also has a config-time preload that picks an upstream to
detect max message size; that path now logs an error and returns,
since there is no task context to report against.

17 files changed:
lualib/lua_scanners/avast.lua
lualib/lua_scanners/clamav.lua
lualib/lua_scanners/cloudmark.lua
lualib/lua_scanners/common.lua
lualib/lua_scanners/dcc.lua
lualib/lua_scanners/expurgate.lua
lualib/lua_scanners/fprot.lua
lualib/lua_scanners/icap.lua
lualib/lua_scanners/kaspersky_av.lua
lualib/lua_scanners/kaspersky_se.lua
lualib/lua_scanners/oletools.lua
lualib/lua_scanners/pyzor.lua
lualib/lua_scanners/razor.lua
lualib/lua_scanners/savapi.lua
lualib/lua_scanners/sophos.lua
lualib/lua_scanners/spamassassin.lua
lualib/lua_scanners/vadesecure.lua

index 6bbb046ce701e693fa75b92ce15373dcd467a953..754c6311ae5b7506a98ba416e6d156a881235b53 100644 (file)
@@ -83,7 +83,10 @@ end
 
 local function avast_check(task, content, digest, rule, maybe_part)
   local function avast_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local CRLF = '\r\n'
@@ -155,6 +158,11 @@ local function avast_check(task, content, digest, rule, maybe_part)
       end
 
       upstream = rule.upstreams:get_upstream_round_robin()
+      if not upstream then
+        common.yield_result(task, rule,
+            'no upstream available for retry', 0.0, 'fail', maybe_part)
+        return
+      end
       addr = upstream:get_addr()
       tcp_opts.upstream = upstream
       tcp_opts.callback = avast_helo_cb
index fc99ab0b914b5329c57896c110c3122054b131d4..5a9c33929892216f482c5cab754502f442db3c7c 100644 (file)
@@ -81,7 +81,10 @@ end
 
 local function clamav_check(task, content, digest, rule, maybe_part)
   local function clamav_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local header = rspamd_util.pack("c9 c1 >I4", "zINSTREAM", "\0",
@@ -98,6 +101,11 @@ local function clamav_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index 12a60abf1d5356fe2d4abd8660a51b35639ea2b3..498b1e9b0467c88ab6a2c826fbe973d905633d71 100644 (file)
@@ -55,6 +55,11 @@ end
 -- Detect cloudmark max size
 local function cloudmark_preload(rule, cfg, ev_base, _)
   local upstream = rule.upstreams:get_upstream_round_robin()
+  if not upstream then
+    rspamd_logger.errx(ev_base or rspamd_config,
+        'cloudmark preload: no upstream available, will retry on next scan')
+    return
+  end
   local addr = upstream:get_addr()
   local function max_message_size_cb(http_err, code, body, _)
     if http_err then
@@ -258,7 +263,10 @@ end
 
 local function cloudmark_check(task, content, digest, rule, maybe_part)
   local function cloudmark_check_uncached()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -336,6 +344,11 @@ local function cloudmark_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
           url = cloudmark_url(rule, addr)
 
index f5e760eec2d7f80cc5467cc30f0b804fb3360297..2cad0a78950ece7c5d88b88f7ca0f17f655b6959 100644 (file)
@@ -509,6 +509,26 @@ local function check_metric_results(task, rule)
   end
 end
 
+--[[
+Pick a round-robin upstream from `rule.upstreams` and emit `symbol_fail` if
+none is currently usable (e.g. all hostnames are still pending DNS resolution
+or every backend has been marked dead). Returns the upstream on success or
+nil on failure — in the nil case `symbol_fail` has already been recorded, so
+the caller should just return.
+--]]
+local function get_upstream_or_fail(task, rule, maybe_part, reason)
+  local upstream = rule.upstreams and rule.upstreams:get_upstream_round_robin()
+
+  if not upstream then
+    yield_result(task, rule,
+        reason or 'no upstream available (DNS pending or all dead)',
+        0.0, 'fail', maybe_part)
+    return nil
+  end
+
+  return upstream
+end
+
 exports.log_clean = log_clean
 exports.yield_result = yield_result
 exports.match_patterns = match_patterns
@@ -517,6 +537,7 @@ exports.save_cache = save_cache
 exports.create_regex_table = create_regex_table
 exports.check_parts_match = check_parts_match
 exports.check_metric_results = check_metric_results
+exports.get_upstream_or_fail = get_upstream_or_fail
 
 setmetatable(exports, {
   __call = function(t, override)
index ed60ae486de27a224dad06fec083908edf88594c..642889d73095a7804de69f779aaba0e0b3c6b52d 100644 (file)
@@ -85,7 +85,10 @@ local function dcc_config(opts)
 end
 
 local function dcc_check(task, content, digest, rule)
-  local upstream = rule.upstreams:get_upstream_round_robin()
+  local upstream = common.get_upstream_or_fail(task, rule, nil)
+  if not upstream then
+    return
+  end
   local addr = upstream:get_addr()
   local retransmits = rule.retransmits
   local client = rule.client
@@ -144,6 +147,11 @@ local function dcc_check(task, content, digest, rule)
 
         -- Select a different upstream!
         upstream = rule.upstreams:get_upstream_round_robin()
+        if not upstream then
+          common.yield_result(task, rule,
+              'no upstream available for retry', 0.0, 'fail')
+          return
+        end
         addr = upstream:get_addr()
 
         lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index 8405374a0a16c246d64ed70f79aacf009a998e43..27f6dc9d6bfee61e4f3815e1b0487b6667d640c1 100644 (file)
@@ -145,7 +145,10 @@ local function expurgate_check(task, content, digest, rule, maybe_part)
       return url
     end
 
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -205,6 +208,11 @@ local function expurgate_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
           url = expurgate_spamd_url(addr)
 
index 5a469c327099748b1a526e491c60150da6bcdf60..790bbbbf48d90a8ffe000e9d41aeb19e934260ba 100644 (file)
@@ -80,7 +80,10 @@ end
 
 local function fprot_check(task, content, digest, rule, maybe_part)
   local function fprot_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local scan_id = task:get_queue_id()
@@ -100,6 +103,11 @@ local function fprot_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index 53285879397baf5ef0a19a7e03698165256ff3ea..dc10db26c04d05da1fbb9a2cc570dcb97bf71c95 100644 (file)
@@ -166,7 +166,10 @@ end
 
 local function icap_check(task, content, digest, rule, maybe_part)
   local function icap_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local http_headers = {}
@@ -218,6 +221,11 @@ local function icap_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule, 'no upstream available for retry', 0.0,
+                'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: retry IP: %s:%s',
@@ -239,7 +247,7 @@ local function icap_check(task, content, digest, rule, maybe_part)
         end
       end
 
-      local function get_req_headers()                
+      local function get_req_headers()
         local in_client_ip = task:get_from_ip()
         local in_client_ip_str = in_client_ip:to_string()
         local req_hlen = 2
index 6ae6b620e33b4bf23e5cd62fe710c63bfffc23ae..a90c8b34991c8f3bbf454cb4d24644ce6d6297dd 100644 (file)
@@ -81,7 +81,10 @@ end
 
 local function kaspersky_check(task, content, digest, rule, maybe_part)
   local function kaspersky_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local fname = string.format('%s/%s.tmp',
@@ -117,6 +120,11 @@ local function kaspersky_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index 7bef586e1416fc7c2bcecb97c8a9d02da8f2d2ac..d5fcff256104984cd92f6d8a81a235aa3c079713 100644 (file)
@@ -110,7 +110,10 @@ local function kaspersky_se_check(task, content, digest, rule, maybe_part)
       return url
     end
 
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -189,6 +192,11 @@ local function kaspersky_se_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
           url = make_url(addr)
 
index 378e09467526533389a34452557ca90f6d93f46b..2ccadca6c61a8f96e05ff1931f9f403970eb6ce5 100644 (file)
@@ -89,7 +89,10 @@ end
 
 local function oletools_check(task, content, digest, rule, maybe_part)
   local function oletools_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local protocol = 'OLEFY/1.0\nMethod: oletools\nRspamd-ID: ' .. task:get_uid() .. '\n\n'
@@ -106,6 +109,11 @@ local function oletools_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index 75c1b4a15754aa12628f0f3ee484eaf795522ed1..a00600ba8550974ffcb4635005a3cc83b410f51a 100644 (file)
@@ -77,7 +77,10 @@ end
 
 local function pyzor_check(task, content, digest, rule)
   local function pyzor_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, nil)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -92,6 +95,11 @@ local function pyzor_check(task, content, digest, rule)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail')
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(N, task, '%s: retry IP: %s:%s err: %s',
index fcc0a8e3a39ab98410577becc4a5511510ca0705..5c91bb8b7e9f6137ef5f1ebc87ca5aeeef110996 100644 (file)
@@ -82,7 +82,10 @@ end
 
 local function razor_check(task, content, digest, rule)
   local function razor_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, nil)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -99,6 +102,11 @@ local function razor_check(task, content, digest, rule)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail')
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: retry IP: %s:%s',
index 293b8f3b5401055931970e2c94f0262799a73746..f5602815df354a4dfff08582e8d1788cd54e5ca3 100644 (file)
@@ -83,7 +83,10 @@ end
 
 local function savapi_check(task, content, digest, rule)
   local function savapi_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, nil)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local fname = string.format('%s/%s.tmp',
@@ -205,6 +208,11 @@ local function savapi_check(task, content, digest, rule)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail')
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index d9b64f1a80e410e161d182a8191974dc04007a0d..6153a5606d2d26769694d6d8e179f8b9bd604345 100644 (file)
@@ -80,7 +80,10 @@ end
 
 local function sophos_check(task, content, digest, rule, maybe_part)
   local function sophos_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
     local protocol = 'SSSP/1.0\n'
@@ -97,6 +100,11 @@ local function sophos_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.name, task, '%s: error: %s; retry IP: %s; retries left: %s',
index f425924d5f3ccb0c732ce0e4f9b254937bf2fb07..cb3fc8b09d01adfe746accdf2b1df64aa479dd5b 100644 (file)
@@ -86,7 +86,10 @@ end
 
 local function spamassassin_check(task, content, digest, rule)
   local function spamassassin_check_uncached ()
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, nil)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -114,6 +117,11 @@ local function spamassassin_check(task, content, digest, rule)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail')
+            return
+          end
           addr = upstream:get_addr()
 
           lua_util.debugm(rule.N, task, '%s: retry IP: %s:%s',
index 826573a890dc2d719fceeeec5a281a9863a98e43..6862ab7ede5b047045aba6272d390501efc94234 100644 (file)
@@ -167,7 +167,10 @@ local function vade_check(task, content, digest, rule, maybe_part)
       return url
     end
 
-    local upstream = rule.upstreams:get_upstream_round_robin()
+    local upstream = common.get_upstream_or_fail(task, rule, maybe_part)
+    if not upstream then
+      return
+    end
     local addr = upstream:get_addr()
     local retransmits = rule.retransmits
 
@@ -241,6 +244,11 @@ local function vade_check(task, content, digest, rule, maybe_part)
 
           -- Select a different upstream!
           upstream = rule.upstreams:get_upstream_round_robin()
+          if not upstream then
+            common.yield_result(task, rule,
+                'no upstream available for retry', 0.0, 'fail', maybe_part)
+            return
+          end
           addr = upstream:get_addr()
           url = vade_url(addr)