← Back
Editing: lua_redis.lua
--[[ Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ]]-- local logger = require "rspamd_logger" local lutil = require "lua_util" local rspamd_util = require "rspamd_util" local T = require "lua_shape.core" local PluginSchema = require "lua_shape.plugin_schema" local exports = {} local E = {} local N = "lua_redis" local db_schema = T.one_of({ T.transform(T.number(), tostring), T.string() }):optional():doc({ summary = "Database number" }) local common_schema = T.table({ timeout = T.one_of({ T.number({ min = 0 }), T.transform(T.string(), lutil.parse_time_interval) }):optional():doc({ summary = "Connection timeout (seconds)" }), db = db_schema, database = db_schema, dbname = db_schema, prefix = T.string():optional():doc({ summary = "Key prefix" }), username = T.string():optional():doc({ summary = "Username" }), password = T.string():optional():doc({ summary = "Password" }), -- TLS options ssl = T.boolean():optional():doc({ summary = "Enable TLS to Redis" }), no_ssl_verify = T.boolean():optional():doc({ summary = "Disable TLS certificate verification" }), ssl_ca = T.string():optional():doc({ summary = "CA certificate file" }), ssl_ca_dir = T.string():optional():doc({ summary = "CA certificates directory" }), ssl_cert = T.string():optional():doc({ summary = "Client certificate file (PEM)" }), ssl_key = T.string():optional():doc({ summary = "Client private key file (PEM)" }), sni = T.string():optional():doc({ summary = "SNI server name override" }), expand_keys = T.boolean():optional():doc({ summary = "Expand keys" }), sentinels = T.one_of({ T.string(), T.array(T.string()) }):optional():doc({ summary = "Sentinel servers" }), sentinel_watch_time = T.one_of({ T.number({ min = 0 }), T.transform(T.string(), lutil.parse_time_interval) }):optional():doc({ summary = "Sentinel watch time" }), sentinel_masters_pattern = T.string():optional():doc({ summary = "Sentinel masters pattern" }), sentinel_master_maxerrors = T.one_of({ T.number(), T.transform(T.string(), tonumber) }):optional():doc({ summary = "Sentinel master max errors" }), sentinel_username = T.string():optional():doc({ summary = "Sentinel username" }), sentinel_password = T.string():optional():doc({ summary = "Sentinel password" }), redis_version = T.one_of({ T.number(), T.transform(T.string(), tonumber) }):optional():doc({ summary = "Redis server version (6 or 7)" }), }, { open = true }) local function extend_fields(...) local res = {} for i = 1, select('#', ...) do local tbl = select(i, ...) if tbl then for k, v in pairs(tbl) do res[k] = v end end end return res end -- Register common schema as redis_common first PluginSchema.register("mixins.redis_common", common_schema) -- Create full redis mixin schema for documentation local redis_mixin_schema = T.one_of({ { name = "common_only", schema = T.table({}, { open = true, mixins = { T.mixin(common_schema, { as = "redis_common" }) } }):doc({ summary = "Redis with no explicit servers (uses localhost)" }) }, { name = "read_servers", schema = T.table({ read_servers = T.one_of({ T.string():doc({ summary = "Single Redis server" }), T.array(T.string()):doc({ summary = "Multiple Redis servers" }) }):doc({ summary = "Read servers list" }) }, { open = true, mixins = { T.mixin(common_schema, { as = "redis_common" }) } }):doc({ summary = "Redis with read-only servers" }) }, { name = "write_servers", schema = T.table({ write_servers = T.one_of({ T.string():doc({ summary = "Single Redis server" }), T.array(T.string()):doc({ summary = "Multiple Redis servers" }) }):doc({ summary = "Write servers list" }) }, { open = true, mixins = { T.mixin(common_schema, { as = "redis_common" }) } }):doc({ summary = "Redis with write-only servers" }) }, { name = "rw_servers", schema = T.table({ read_servers = T.one_of({ T.string():doc({ summary = "Single Redis server" }), T.array(T.string()):doc({ summary = "Multiple Redis servers" }) }):doc({ summary = "Read servers list" }), write_servers = T.one_of({ T.string():doc({ summary = "Single Redis server" }), T.array(T.string()):doc({ summary = "Multiple Redis servers" }) }):doc({ summary = "Write servers list" }) }, { open = true, mixins = { T.mixin(common_schema, { as = "redis_common" }) } }):doc({ summary = "Redis with separate read and write servers" }) }, { name = "servers", schema = T.table({ servers = T.one_of({ T.string():doc({ summary = "Single Redis server" }), T.array(T.string()):doc({ summary = "Multiple Redis servers" }) }):doc({ summary = "Redis servers list" }) }, { open = true, mixins = { T.mixin(common_schema, { as = "redis_common" }) } }):doc({ summary = "Redis with server list" }) }, { name = "server_legacy", schema = T.table({ server = T.one_of({ T.string():doc({ summary = "Single Redis server" }), T.array(T.string()):doc({ summary = "Multiple Redis servers" }) }):doc({ summary = "Redis server (legacy)" }) }, { open = true, mixins = { T.mixin(common_schema, { as = "redis_common" }) } }):doc({ summary = "Redis with legacy server parameter" }) } }):doc({ summary = "Redis connection configuration" }) PluginSchema.register("mixins.redis", redis_mixin_schema) local enrich_schema = function(external) local external_fields = external or {} return T.one_of({ { name = "common_only", schema = T.table(extend_fields(external_fields), { open = true, mixins = { T.mixin(common_schema, { as = "redis" }) } }) }, { name = "read_servers", schema = T.table(extend_fields(external_fields, { read_servers = T.one_of({ T.string(), T.array(T.string()) }) }), { open = true, mixins = { T.mixin(common_schema, { as = "redis" }) } }) }, { name = "write_servers", schema = T.table(extend_fields(external_fields, { write_servers = T.one_of({ T.string(), T.array(T.string()) }) }), { open = true, mixins = { T.mixin(common_schema, { as = "redis" }) } }) }, { name = "rw_servers", schema = T.table(extend_fields(external_fields, { read_servers = T.one_of({ T.string(), T.array(T.string()) }), write_servers = T.one_of({ T.string(), T.array(T.string()) }) }), { open = true, mixins = { T.mixin(common_schema, { as = "redis" }) } }) }, { name = "servers", schema = T.table(extend_fields(external_fields, { servers = T.one_of({ T.string(), T.array(T.string()) }) }), { open = true, mixins = { T.mixin(common_schema, { as = "redis" }) } }) }, { name = "server_legacy", schema = T.table(extend_fields(external_fields, { server = T.one_of({ T.string(), T.array(T.string()) }) }), { open = true, mixins = { T.mixin(common_schema, { as = "redis" }) } }) } }) end exports.enrich_schema = enrich_schema local function flatten_redis_table(tbl) local res = {} for i = 1, #tbl, 2 do res[tbl[i]] = tbl[i + 1] end return res end local function redis_query_sentinel(ev_base, params, initialised) -- Coroutines syntax local rspamd_redis = require "rspamd_redis" local sentinels = params.sentinels local addr = sentinels:get_upstream_round_robin() if not addr then logger.errx(rspamd_config, 'no sentinel upstream available (all dead or pending DNS resolution); skipping sentinel watch tick') return end local host = addr:get_addr() local masters = {} local process_masters -- Function that is called to process masters data local function masters_cb(err, result) if not err and result and type(result) == 'table' then local pending_subrequests = 0 for _, m in ipairs(result) do local master = flatten_redis_table(m) -- Wrap IPv6-addresses in brackets if (master.ip:match(":")) then master.ip = "[" .. master.ip .. "]" end if params.sentinel_masters_pattern then if master.name:match(params.sentinel_masters_pattern) then lutil.debugm(N, 'found master %s with ip %s and port %s', master.name, master.ip, master.port) masters[master.name] = master else lutil.debugm(N, 'skip master %s with ip %s and port %s, pattern %s', master.name, master.ip, master.port, params.sentinel_masters_pattern) end else lutil.debugm(N, 'found master %s with ip %s and port %s', master.name, master.ip, master.port) masters[master.name] = master end end -- For each master we need to get a list of slaves for k, v in pairs(masters) do v.slaves = {} local function slaves_cb(slave_err, slave_result) if not slave_err and type(slave_result) == 'table' then for _, s in ipairs(slave_result) do local slave = flatten_redis_table(s) lutil.debugm(N, rspamd_config, 'found slave for master %s with ip %s and port %s', v.name, slave.ip, slave.port) -- Wrap IPv6-addresses in brackets if (slave.ip:match(":")) then slave.ip = "[" .. slave.ip .. "]" end v.slaves[#v.slaves + 1] = slave end else logger.errx('cannot get slaves data from Redis Sentinel %s: %s', host:to_string(true), slave_err) addr:fail() end pending_subrequests = pending_subrequests - 1 if pending_subrequests == 0 then -- Finalize masters and slaves process_masters() end end local ret = rspamd_redis.make_request { host = addr:get_addr(), timeout = params.timeout, username = params.sentinel_username, password = params.sentinel_password, config = rspamd_config, ev_base = ev_base, cmd = 'SENTINEL', args = { 'slaves', k }, no_pool = true, callback = slaves_cb } if not ret then logger.errx(rspamd_config, 'cannot connect sentinel when query slaves at address: %s', host:to_string(true)) addr:fail() else pending_subrequests = pending_subrequests + 1 end end addr:ok() else logger.errx('cannot get masters data from Redis Sentinel %s: %s', host:to_string(true), err) addr:fail() end end local ret = rspamd_redis.make_request { host = addr:get_addr(), timeout = params.timeout, config = rspamd_config, ev_base = ev_base, username = params.sentinel_username, password = params.sentinel_password, cmd = 'SENTINEL', args = { 'masters' }, no_pool = true, callback = masters_cb, } if not ret then logger.errx(rspamd_config, 'cannot connect sentinel at address: %s', host:to_string(true)) addr:fail() end process_masters = function() -- We now form new strings for masters and slaves local read_servers_tbl, write_servers_tbl = {}, {} for _, master in pairs(masters) do write_servers_tbl[#write_servers_tbl + 1] = string.format( '%s:%s', master.ip, master.port ) read_servers_tbl[#read_servers_tbl + 1] = string.format( '%s:%s', master.ip, master.port ) for _, slave in ipairs(master.slaves) do if slave['master-link-status'] == 'ok' then read_servers_tbl[#read_servers_tbl + 1] = string.format( '%s:%s', slave.ip, slave.port ) end end end table.sort(read_servers_tbl) table.sort(write_servers_tbl) local read_servers_str = table.concat(read_servers_tbl, ',') local write_servers_str = table.concat(write_servers_tbl, ',') lutil.debugm(N, rspamd_config, 'new servers list: %s read; %s write', read_servers_str, write_servers_str) if read_servers_str ~= params.read_servers_str then local upstream_list = require "rspamd_upstream_list" local read_upstreams = upstream_list.create(rspamd_config, read_servers_str, 6379) if read_upstreams then logger.infox(rspamd_config, 'sentinel %s: replace read servers with new list: %s', host:to_string(true), read_servers_str) params.read_servers = read_upstreams params.read_servers_str = read_servers_str end end if write_servers_str ~= params.write_servers_str then local upstream_list = require "rspamd_upstream_list" local write_upstreams = upstream_list.create(rspamd_config, write_servers_str, 6379) if write_upstreams then logger.infox(rspamd_config, 'sentinel %s: replace write servers with new list: %s', host:to_string(true), write_servers_str) params.write_servers = write_upstreams params.write_servers_str = write_servers_str local queried = false local function monitor_failures(up, _, count) if count > params.sentinel_master_maxerrors and not queried then logger.infox(rspamd_config, 'sentinel: master with address %s, caused %s failures, try to query sentinel', host:to_string(true), count) queried = true -- Avoid multiple checks caused by this monitor redis_query_sentinel(ev_base, params, true) end end write_upstreams:add_watcher('failure', monitor_failures) end end end end local function add_redis_sentinels(params) local upstream_list = require "rspamd_upstream_list" local upstreams_sentinels = upstream_list.create(rspamd_config, params.sentinels, 5000) if not upstreams_sentinels then logger.errx(rspamd_config, 'cannot load redis sentinels string: %s', params.sentinels) return end params.sentinels = upstreams_sentinels if not params.sentinel_watch_time then params.sentinel_watch_time = 60 -- Each minute end if not params.sentinel_master_maxerrors then params.sentinel_master_maxerrors = 2 -- Maximum number of errors before rechecking end rspamd_config:add_on_load(function(_, ev_base, worker) local initialised = false if worker:is_scanner() or worker:get_type() == 'fuzzy' then rspamd_config:add_periodic(ev_base, 0.0, function() redis_query_sentinel(ev_base, params, initialised) initialised = true return params.sentinel_watch_time end, false) end end) end local cached_results = {} local function calculate_redis_hash(params) local cr = require "rspamd_cryptobox_hash" local h = cr.create() local function rec_hash(k, v) if type(v) == 'string' then h:update(k) h:update(v) elseif type(v) == 'number' then h:update(k) h:update(tostring(v)) elseif type(v) == 'table' then for kk, vv in pairs(v) do rec_hash(kk, vv) end end end rec_hash('top', params) return h:base32() end local function process_redis_opts(options, redis_params) local default_timeout = 1.0 local default_expand_keys = false if not redis_params['timeout'] or redis_params['timeout'] == default_timeout then if options['timeout'] then redis_params['timeout'] = tonumber(options['timeout']) else redis_params['timeout'] = default_timeout end end if options['prefix'] and not redis_params['prefix'] then redis_params['prefix'] = options['prefix'] end if options['redis_version'] and not redis_params['redis_version'] then redis_params['redis_version'] = tonumber(options['redis_version']) end if type(options['expand_keys']) == 'boolean' then redis_params['expand_keys'] = options['expand_keys'] else redis_params['expand_keys'] = default_expand_keys end if not redis_params['db'] then if options['db'] then redis_params['db'] = tostring(options['db']) elseif options['dbname'] then redis_params['db'] = tostring(options['dbname']) elseif options['database'] then redis_params['db'] = tostring(options['database']) end end if options['username'] and not redis_params['username'] then redis_params['username'] = options['username'] end if options['password'] and not redis_params['password'] then redis_params['password'] = options['password'] end -- TLS passthrough if options['ssl'] ~= nil and redis_params['ssl'] == nil then redis_params['ssl'] = options['ssl'] and true or false end if options['no_ssl_verify'] ~= nil and redis_params['no_ssl_verify'] == nil then redis_params['no_ssl_verify'] = options['no_ssl_verify'] and true or false end if options['ssl_ca'] and not redis_params['ssl_ca'] then redis_params['ssl_ca'] = options['ssl_ca'] end if options['ssl_ca_dir'] and not redis_params['ssl_ca_dir'] then redis_params['ssl_ca_dir'] = options['ssl_ca_dir'] end if options['ssl_cert'] and not redis_params['ssl_cert'] then redis_params['ssl_cert'] = options['ssl_cert'] end if options['ssl_key'] and not redis_params['ssl_key'] then redis_params['ssl_key'] = options['ssl_key'] end if options['sni'] and not redis_params['sni'] then redis_params['sni'] = options['sni'] end if not redis_params.sentinels and options.sentinels then redis_params.sentinels = options.sentinels end if options['sentinel_masters_pattern'] and not redis_params['sentinel_masters_pattern'] then redis_params['sentinel_masters_pattern'] = options['sentinel_masters_pattern'] end if options['sentinel_watch_time'] and not redis_params['sentinel_watch_time'] then redis_params['sentinel_watch_time'] = options['sentinel_watch_time'] end if options['sentinel_username'] and not redis_params['sentinel_username'] then redis_params['sentinel_username'] = options['sentinel_username'] end if options['sentinel_password'] and not redis_params['sentinel_password'] then redis_params['sentinel_password'] = options['sentinel_password'] end if options['sentinel_master_maxerrors'] and not redis_params['sentinel_master_maxerrors'] then redis_params['sentinel_master_maxerrors'] = options['sentinel_master_maxerrors'] end end local function enrich_defaults(rspamd_config, module, redis_params) if rspamd_config then local opts = rspamd_config:get_all_opt('redis') if opts then if module then if opts[module] then process_redis_opts(opts[module], redis_params) end end process_redis_opts(opts, redis_params) end end end local function maybe_return_cached(redis_params) local h = calculate_redis_hash(redis_params) if cached_results[h] then lutil.debugm(N, 'reused redis server: %s', redis_params) return cached_results[h] end redis_params.hash = h cached_results[h] = redis_params if not redis_params.read_only and redis_params.sentinels then add_redis_sentinels(redis_params) end lutil.debugm(N, 'loaded new redis server: %s', redis_params) return redis_params end --[[[ -- @module lua_redis -- This module contains helper functions for working with Redis --]] local function process_redis_options(options, rspamd_config, result) local default_port = 6379 local upstream_list = require "rspamd_upstream_list" local read_only = true -- Try to get read servers: local upstreams_read, upstreams_write if options['read_servers'] then if rspamd_config then upstreams_read = upstream_list.create(rspamd_config, options['read_servers'], default_port) else upstreams_read = upstream_list.create(options['read_servers'], default_port) end result.read_servers_str = options['read_servers'] elseif options['servers'] then if rspamd_config then upstreams_read = upstream_list.create(rspamd_config, options['servers'], default_port) else upstreams_read = upstream_list.create(options['servers'], default_port) end result.read_servers_str = options['servers'] read_only = false elseif options['server'] then if rspamd_config then upstreams_read = upstream_list.create(rspamd_config, options['server'], default_port) else upstreams_read = upstream_list.create(options['server'], default_port) end result.read_servers_str = options['server'] read_only = false end if upstreams_read then if options['write_servers'] then if rspamd_config then upstreams_write = upstream_list.create(rspamd_config, options['write_servers'], default_port) else upstreams_write = upstream_list.create(options['write_servers'], default_port) end result.write_servers_str = options['write_servers'] read_only = false elseif not read_only then upstreams_write = upstreams_read result.write_servers_str = result.read_servers_str end end -- Store options process_redis_opts(options, result) if read_only and not upstreams_write then result.read_only = true elseif upstreams_write then result.read_only = false end if upstreams_read then result.read_servers = upstreams_read if upstreams_write then result.write_servers = upstreams_write end return true end lutil.debugm(N, rspamd_config, 'cannot load redis server from obj: %s, processed to %s', options, result) return false end --[[[ @function try_load_redis_servers(options, rspamd_config, no_fallback) Tries to load redis servers from the specified `options` object. Returns `redis_params` table or nil in case of failure --]] exports.try_load_redis_servers = function(options, rspamd_config, no_fallback, module_name) local result = {} if process_redis_options(options, rspamd_config, result) then if not no_fallback then enrich_defaults(rspamd_config, module_name, result) end return maybe_return_cached(result) end end -- This function parses redis server definition using either -- specific server string for this module or global -- redis section local function rspamd_parse_redis_server(module_name, module_opts, no_fallback) local result = {} -- Try local options local opts lutil.debugm(N, rspamd_config, 'try load redis config for: %s', module_name) if not module_opts then opts = rspamd_config:get_all_opt(module_name) else opts = module_opts end if opts then local ret if opts.redis then ret = process_redis_options(opts.redis, rspamd_config, result) if ret then if not no_fallback then enrich_defaults(rspamd_config, module_name, result) end return maybe_return_cached(result) end end ret = process_redis_options(opts, rspamd_config, result) if ret then if not no_fallback then enrich_defaults(rspamd_config, module_name, result) end return maybe_return_cached(result) end end if no_fallback then logger.infox(rspamd_config, "cannot find Redis definitions for %s and fallback is disabled", module_name) return nil end -- Try global options opts = rspamd_config:get_all_opt('redis') if opts then local ret if opts[module_name] then ret = process_redis_options(opts[module_name], rspamd_config, result) if ret then return maybe_return_cached(result) end else ret = process_redis_options(opts, rspamd_config, result) -- Exclude disabled if opts['disabled_modules'] then for _, v in ipairs(opts['disabled_modules']) do if v == module_name then logger.infox(rspamd_config, "NOT using default redis server for module %s: it is disabled", module_name) return nil end end end if ret then logger.infox(rspamd_config, "use default Redis settings for %s", module_name) return maybe_return_cached(result) end end end if result.read_servers then return maybe_return_cached(result) end return nil end --[[[ -- @function lua_redis.parse_redis_server(module_name, module_opts, no_fallback) -- Extracts Redis server settings from configuration -- @param {string} module_name name of module to get settings for -- @param {table} module_opts settings for module or `nil` to fetch them from configuration -- @param {boolean} no_fallback should be `true` if global settings must not be used -- @return {table} redis server settings -- @example -- local rconfig = lua_redis.parse_redis_server('my_module') -- -- rconfig contains upstream_list objects in ['write_servers'] and ['read_servers'] -- -- ['timeout'] contains timeout in seconds -- -- ['expand_keys'] if true tells that redis key expansion is enabled --]] exports.rspamd_parse_redis_server = rspamd_parse_redis_server exports.parse_redis_server = rspamd_parse_redis_server local process_cmd = { bitop = function(args) local idx_l = {} for i = 2, #args do table.insert(idx_l, i) end return idx_l end, blpop = function(args) local idx_l = {} for i = 1, #args - 1 do table.insert(idx_l, i) end return idx_l end, eval = function(args) local idx_l = {} local numkeys = args[2] if numkeys and tonumber(numkeys) >= 1 then for i = 3, numkeys + 2 do table.insert(idx_l, i) end end return idx_l end, set = function(args) return { 1 } end, mget = function(args) local idx_l = {} for i = 1, #args do table.insert(idx_l, i) end return idx_l end, mset = function(args) local idx_l = {} for i = 1, #args, 2 do table.insert(idx_l, i) end return idx_l end, sdiffstore = function(args) local idx_l = {} for i = 2, #args do table.insert(idx_l, i) end return idx_l end, smove = function(args) return { 1, 2 } end, script = function() end } process_cmd.append = process_cmd.set process_cmd.auth = process_cmd.script process_cmd.bgrewriteaof = process_cmd.script process_cmd.bgsave = process_cmd.script process_cmd.bitcount = process_cmd.set process_cmd.bitfield = process_cmd.set process_cmd.bitpos = process_cmd.set process_cmd.brpop = process_cmd.blpop process_cmd.brpoplpush = process_cmd.blpop process_cmd.client = process_cmd.script process_cmd.cluster = process_cmd.script process_cmd.command = process_cmd.script process_cmd.config = process_cmd.script process_cmd.dbsize = process_cmd.script process_cmd.debug = process_cmd.script process_cmd.decr = process_cmd.set process_cmd.decrby = process_cmd.set process_cmd.del = process_cmd.mget process_cmd.discard = process_cmd.script process_cmd.dump = process_cmd.set process_cmd.echo = process_cmd.script process_cmd.evalsha = process_cmd.eval process_cmd.exec = process_cmd.script process_cmd.exists = process_cmd.mget process_cmd.expire = process_cmd.set process_cmd.expireat = process_cmd.set process_cmd.flushall = process_cmd.script process_cmd.flushdb = process_cmd.script process_cmd.geoadd = process_cmd.set process_cmd.geohash = process_cmd.set process_cmd.geopos = process_cmd.set process_cmd.geodist = process_cmd.set process_cmd.georadius = process_cmd.set process_cmd.georadiusbymember = process_cmd.set process_cmd.get = process_cmd.set process_cmd.getbit = process_cmd.set process_cmd.getrange = process_cmd.set process_cmd.getset = process_cmd.set process_cmd.hdel = process_cmd.set process_cmd.hexists = process_cmd.set process_cmd.hget = process_cmd.set process_cmd.hgetall = process_cmd.set process_cmd.hincrby = process_cmd.set process_cmd.hincrbyfloat = process_cmd.set process_cmd.hkeys = process_cmd.set process_cmd.hlen = process_cmd.set process_cmd.hmget = process_cmd.set process_cmd.hmset = process_cmd.set process_cmd.hscan = process_cmd.set process_cmd.hset = process_cmd.set process_cmd.hsetnx = process_cmd.set process_cmd.hstrlen = process_cmd.set process_cmd.hvals = process_cmd.set process_cmd.incr = process_cmd.set process_cmd.incrby = process_cmd.set process_cmd.incrbyfloat = process_cmd.set process_cmd.info = process_cmd.script process_cmd.keys = process_cmd.script process_cmd.lastsave = process_cmd.script process_cmd.lindex = process_cmd.set process_cmd.linsert = process_cmd.set process_cmd.llen = process_cmd.set process_cmd.lpop = process_cmd.set process_cmd.lpush = process_cmd.set process_cmd.lpushx = process_cmd.set process_cmd.lrange = process_cmd.set process_cmd.lrem = process_cmd.set process_cmd.lset = process_cmd.set process_cmd.ltrim = process_cmd.set process_cmd.migrate = process_cmd.script process_cmd.monitor = process_cmd.script process_cmd.move = process_cmd.set process_cmd.msetnx = process_cmd.mset process_cmd.multi = process_cmd.script process_cmd.object = process_cmd.script process_cmd.persist = process_cmd.set process_cmd.pexpire = process_cmd.set process_cmd.pexpireat = process_cmd.set process_cmd.pfadd = process_cmd.set process_cmd.pfcount = process_cmd.set process_cmd.pfmerge = process_cmd.mget process_cmd.ping = process_cmd.script process_cmd.psetex = process_cmd.set process_cmd.psubscribe = process_cmd.script process_cmd.pubsub = process_cmd.script process_cmd.pttl = process_cmd.set process_cmd.publish = process_cmd.script process_cmd.punsubscribe = process_cmd.script process_cmd.quit = process_cmd.script process_cmd.randomkey = process_cmd.script process_cmd.readonly = process_cmd.script process_cmd.readwrite = process_cmd.script process_cmd.rename = process_cmd.mget process_cmd.renamenx = process_cmd.mget process_cmd.restore = process_cmd.set process_cmd.role = process_cmd.script process_cmd.rpop = process_cmd.set process_cmd.rpoplpush = process_cmd.mget process_cmd.rpush = process_cmd.set process_cmd.rpushx = process_cmd.set process_cmd.sadd = process_cmd.set process_cmd.save = process_cmd.script process_cmd.scard = process_cmd.set process_cmd.sdiff = process_cmd.mget process_cmd.select = process_cmd.script process_cmd.setbit = process_cmd.set process_cmd.setex = process_cmd.set process_cmd.setnx = process_cmd.set process_cmd.sinterstore = process_cmd.sdiff process_cmd.sismember = process_cmd.set process_cmd.slaveof = process_cmd.script process_cmd.slowlog = process_cmd.script process_cmd.smembers = process_cmd.script process_cmd.sort = process_cmd.set process_cmd.spop = process_cmd.set process_cmd.srandmember = process_cmd.set process_cmd.srem = process_cmd.set process_cmd.strlen = process_cmd.set process_cmd.subscribe = process_cmd.script process_cmd.sunion = process_cmd.mget process_cmd.sunionstore = process_cmd.mget process_cmd.swapdb = process_cmd.script process_cmd.sync = process_cmd.script process_cmd.time = process_cmd.script process_cmd.touch = process_cmd.mget process_cmd.ttl = process_cmd.set process_cmd.type = process_cmd.set process_cmd.unsubscribe = process_cmd.script process_cmd.unlink = process_cmd.mget process_cmd.unwatch = process_cmd.script process_cmd.wait = process_cmd.script process_cmd.watch = process_cmd.mget process_cmd.zadd = process_cmd.set process_cmd.zcard = process_cmd.set process_cmd.zcount = process_cmd.set process_cmd.zincrby = process_cmd.set process_cmd.zinterstore = process_cmd.eval process_cmd.zlexcount = process_cmd.set process_cmd.zrange = process_cmd.set process_cmd.zrangebylex = process_cmd.set process_cmd.zrank = process_cmd.set process_cmd.zrem = process_cmd.set process_cmd.zrembylex = process_cmd.set process_cmd.zrembyrank = process_cmd.set process_cmd.zrembyscore = process_cmd.set process_cmd.zrevrange = process_cmd.set process_cmd.zrevrangebyscore = process_cmd.set process_cmd.zrevrank = process_cmd.set process_cmd.zscore = process_cmd.set process_cmd.zunionstore = process_cmd.eval process_cmd.scan = process_cmd.script process_cmd.sscan = process_cmd.set process_cmd.hscan = process_cmd.set process_cmd.zscan = process_cmd.set local function get_key_indexes(cmd, args) local idx_l = {} cmd = string.lower(cmd) if process_cmd[cmd] then idx_l = process_cmd[cmd](args) else logger.warnx(rspamd_config, "Don't know how to extract keys for %s Redis command", cmd) end return idx_l end local gen_meta = { principal_recipient = function(task) return task:get_principal_recipient() end, principal_recipient_domain = function(task) local p = task:get_principal_recipient() if not p then return end return string.match(p, '.*@(.*)') end, ip = function(task) local i = task:get_ip() if i and i:is_valid() then return i:to_string() end end, from = function(task) return ((task:get_from('smtp') or E)[1] or E)['addr'] end, from_domain = function(task) return ((task:get_from('smtp') or E)[1] or E)['domain'] end, from_domain_or_helo_domain = function(task) local d = ((task:get_from('smtp') or E)[1] or E)['domain'] if d and #d > 0 then return d end return task:get_helo() end, mime_from = function(task) return ((task:get_from('mime') or E)[1] or E)['addr'] end, mime_from_domain = function(task) return ((task:get_from('mime') or E)[1] or E)['domain'] end, } local function gen_get_esld(f) return function(task) local d = f(task) if not d then return end return rspamd_util.get_tld(d) end end gen_meta.smtp_from = gen_meta.from gen_meta.smtp_from_domain = gen_meta.from_domain gen_meta.smtp_from_domain_or_helo_domain = gen_meta.from_domain_or_helo_domain gen_meta.esld_principal_recipient_domain = gen_get_esld(gen_meta.principal_recipient_domain) gen_meta.esld_from_domain = gen_get_esld(gen_meta.from_domain) gen_meta.esld_smtp_from_domain = gen_meta.esld_from_domain gen_meta.esld_mime_from_domain = gen_get_esld(gen_meta.mime_from_domain) gen_meta.esld_from_domain_or_helo_domain = gen_get_esld(gen_meta.from_domain_or_helo_domain) gen_meta.esld_smtp_from_domain_or_helo_domain = gen_meta.esld_from_domain_or_helo_domain local function get_key_expansion_metadata(task) local md_mt = { __index = function(self, k) k = string.lower(k) local v = rawget(self, k) if v then return v end if gen_meta[k] then v = gen_meta[k](task) rawset(self, k, v) end return v end, } local lazy_meta = {} setmetatable(lazy_meta, md_mt) return lazy_meta end -- Performs async call to redis hiding all complexity inside function -- task - rspamd_task -- redis_params - valid params returned by rspamd_parse_redis_server -- key - key to select upstream or nil to select round-robin/master-slave -- is_write - true if need to write to redis server -- callback - function to be called upon request is completed -- command - redis command -- args - table of arguments -- extra_opts - table of optional request arguments local function rspamd_redis_make_request(task, redis_params, key, is_write, callback, command, args, extra_opts) local addr local function rspamd_redis_make_request_cb(err, data) if err then addr:fail() else addr:ok() end if callback then callback(err, data, addr) end end if not task or not redis_params or not command then return false, nil, nil end local rspamd_redis = require "rspamd_redis" if key then if is_write then addr = redis_params['write_servers']:get_upstream_by_hash(key) else addr = redis_params['read_servers']:get_upstream_by_hash(key) end else if is_write then addr = redis_params['write_servers']:get_upstream_master_slave(key) else addr = redis_params['read_servers']:get_upstream_round_robin(key) end end if not addr then logger.errx(task, 'cannot select redis server (all dead or pending DNS resolution)') return false, nil, nil end if redis_params['expand_keys'] then local m = get_key_expansion_metadata(task) local indexes = get_key_indexes(command, args) for _, i in ipairs(indexes) do args[i] = lutil.template(args[i], m) end end local ip_addr = addr:get_addr() local options = { task = task, callback = rspamd_redis_make_request_cb, host = ip_addr, timeout = redis_params['timeout'], cmd = command, args = args } if extra_opts then for k, v in pairs(extra_opts) do options[k] = v end end if redis_params['username'] then options['username'] = redis_params['username'] end if redis_params['password'] then options['password'] = redis_params['password'] end if redis_params['db'] then options['dbname'] = redis_params['db'] end -- TLS options if redis_params.ssl ~= nil then options.ssl = redis_params.ssl end if redis_params.no_ssl_verify ~= nil then options.no_ssl_verify = redis_params.no_ssl_verify end if redis_params.ssl_ca then options.ssl_ca = redis_params.ssl_ca end if redis_params.ssl_ca_dir then options.ssl_ca_dir = redis_params.ssl_ca_dir end if redis_params.ssl_cert then options.ssl_cert = redis_params.ssl_cert end if redis_params.ssl_key then options.ssl_key = redis_params.ssl_key end if redis_params.sni then options.sni = redis_params.sni end lutil.debugm(N, task, 'perform request to redis server' .. ' (host=%s, timeout=%s): cmd: %s', ip_addr, options.timeout, options.cmd) local ret, conn = rspamd_redis.make_request(options) if not ret then addr:fail() logger.warnx(task, "cannot make redis request to: %s", tostring(ip_addr)) end return ret, conn, addr end --[[[ -- @function lua_redis.redis_make_request(task, redis_params, key, is_write, callback, command, args) -- Sends a request to Redis -- @param {rspamd_task} task task object -- @param {table} redis_params redis configuration in format returned by lua_redis.parse_redis_server() -- @param {string} key key to use for sharding -- @param {boolean} is_write should be `true` if we are performing a write operating -- @param {function} callback callback function (first parameter is error if applicable, second is a 2D array (table)) -- @param {string} command Redis command to run -- @param {table} args Numerically indexed table containing arguments for command --]] exports.rspamd_redis_make_request = rspamd_redis_make_request exports.redis_make_request = rspamd_redis_make_request local function redis_make_request_taskless(ev_base, cfg, redis_params, key, is_write, callback, command, args, extra_opts) if not ev_base or not redis_params or not command then return false, nil, nil end local addr local function rspamd_redis_make_request_cb(err, data) if err then addr:fail() else addr:ok() end if callback then callback(err, data, addr) end end local rspamd_redis = require "rspamd_redis" if key then if is_write then addr = redis_params['write_servers']:get_upstream_by_hash(key) else addr = redis_params['read_servers']:get_upstream_by_hash(key) end else if is_write then addr = redis_params['write_servers']:get_upstream_master_slave(key) else addr = redis_params['read_servers']:get_upstream_round_robin(key) end end if not addr then logger.errx(cfg, 'cannot select redis server (all dead or pending DNS resolution)') return false, nil, nil end local options = { ev_base = ev_base, config = cfg, callback = rspamd_redis_make_request_cb, host = addr:get_addr(), timeout = redis_params['timeout'], cmd = command, args = args } if extra_opts then for k, v in pairs(extra_opts) do options[k] = v end end if redis_params['username'] then options['username'] = redis_params['username'] end if redis_params['password'] then options['password'] = redis_params['password'] end if redis_params['db'] then options['dbname'] = redis_params['db'] end -- TLS options if redis_params.ssl ~= nil then options.ssl = redis_params.ssl end if redis_params.no_ssl_verify ~= nil then options.no_ssl_verify = redis_params.no_ssl_verify end if redis_params.ssl_ca then options.ssl_ca = redis_params.ssl_ca end if redis_params.ssl_ca_dir then options.ssl_ca_dir = redis_params.ssl_ca_dir end if redis_params.ssl_cert then options.ssl_cert = redis_params.ssl_cert end if redis_params.ssl_key then options.ssl_key = redis_params.ssl_key end if redis_params.sni then options.sni = redis_params.sni end lutil.debugm(N, cfg, 'perform taskless request to redis server' .. ' (host=%s, timeout=%s): cmd: %s', options.host:tostring(true), options.timeout, options.cmd) local ret, conn = rspamd_redis.make_request(options) if not ret then logger.errx('cannot execute redis request') addr:fail() end return ret, conn, addr end --[[[ -- @function lua_redis.redis_make_request_taskless(ev_base, cfg, redis_params, key, is_write, callback, command, args) -- Sends a request to Redis in context where `task` is not available for some specific use-cases -- Identical to redis_make_request() except in that first parameter is an `event base` object and the second one is the 'config' object --]] exports.rspamd_redis_make_request_taskless = redis_make_request_taskless exports.redis_make_request_taskless = redis_make_request_taskless local redis_scripts = { } local function script_set_loaded(script) if script.sha then script.loaded = true end script.pending_upload = false -- Allow further reload local wait_table = {} for _, s in ipairs(script.waitq) do table.insert(wait_table, s) end script.waitq = {} for _, s in ipairs(wait_table) do s(script.loaded) end end local function prepare_redis_call(script) local servers = {} local options = {} if script.redis_params.read_servers then servers = lutil.table_merge(servers, script.redis_params.read_servers:all_upstreams()) end if script.redis_params.write_servers then servers = lutil.table_merge(servers, script.redis_params.write_servers:all_upstreams()) end -- Call load script on each server, set loaded flag if not script.servers_ready then script.servers_ready = { } -- possible values for each server: unsent, tempfail, failed, done end for idx, s in ipairs(servers) do local server_addr = s:get_addr() if not server_addr then -- Pending DNS resolution; mark as tempfail so the next attempt retries. -- Insert a placeholder opt (server_idx-aligned) so options/servers_ready -- indexing stays consistent for the consumer loop in load_script_task / -- load_script_taskless. The placeholder is detected via .skip = true. script.servers_ready[idx] = "tempfail" logger.infox(rspamd_config, 'skipping SCRIPT LOAD for upstream %s: address not resolved yet', s:get_name()) table.insert(options, { skip = true, upstream = s, server_idx = idx }) else local cur_opts = { host = server_addr, timeout = script.redis_params['timeout'], cmd = 'SCRIPT', args = { 'LOAD', script.script }, upstream = s, server_idx = idx, } -- By default we start from unsent status if not script.servers_ready[idx] then script.servers_ready[idx] = "unsent" end if script.redis_params['username'] then cur_opts['username'] = script.redis_params['username'] end if script.redis_params['password'] then cur_opts['password'] = script.redis_params['password'] end if script.redis_params['db'] then cur_opts['dbname'] = script.redis_params['db'] end table.insert(options, cur_opts) end end return options end local function is_any_server_ready(script) for _, s in ipairs(script.servers_ready) do if s == "done" then return true end end return false end local function is_all_servers_failed(script) for _, s in ipairs(script.servers_ready) do if s == "unsent" or s == "tempfail" or s == "done" then return false end end return true end local function script_description(script) return script.filename and ("from file: " .. script.filename) or ("with id: " .. script.id) end local function load_script_task(script, task, is_write) local rspamd_redis = require "rspamd_redis" local opts = prepare_redis_call(script) for idx, opt in ipairs(opts) do -- opt.skip means the upstream was not resolved when prepare_redis_call ran; -- servers_ready is already tempfailed and will be retried on the next pass. if not opt.skip and script.servers_ready[idx] ~= 'done' then opt.task = task opt.is_write = is_write opt.callback = function(err, data) if err then if string.match(err, 'ERR') then logger.errx(task, 'cannot upload script %s to %s: %s; registered from: %s:%s', script_description(script), opt.upstream:get_addr():to_string(true), err, script.caller.short_src, script.caller.currentline) opt.upstream:fail() script.servers_ready[idx] = "failed" return else -- Assume temporary error logger.infox(task, 'temporary error uploading script %s to %s: %s; registered from: %s:%s', script_description(script), opt.upstream:get_addr():to_string(true), err, script.caller.short_src, script.caller.currentline) script.servers_ready[idx] = "tempfail" return end else opt.upstream:ok() logger.infox(task, "uploaded redis script to %s %s, sha: %s", opt.upstream:get_addr():to_string(true), script_description(script), data) script.sha = data -- We assume that sha is the same on all servers script.servers_ready[idx] = "done" end if is_any_server_ready(script) then script_set_loaded(script) elseif is_all_servers_failed(script) then script.pending_upload = false script.fatal_error = "cannot upload script to any server" end end -- callback local ret = rspamd_redis.make_request(opt) if not ret then logger.errx('cannot execute redis request to load script on %s', opt.upstream:get_addr()) script.servers_ready[idx] = "failed" opt.upstream:fail() end end if is_any_server_ready(script) then script_set_loaded(script) elseif is_all_servers_failed(script) then script.pending_upload = false script.fatal_error = "cannot upload script to any server" end end end local function load_script_taskless(script, cfg, ev_base, is_write) local rspamd_redis = require "rspamd_redis" local opts = prepare_redis_call(script) for idx, opt in ipairs(opts) do if not opt.skip and script.servers_ready[idx] ~= 'done' then opt.config = cfg opt.ev_base = ev_base opt.is_write = is_write opt.callback = function(err, data) if err then if string.match(err, 'ERR') then logger.errx(cfg, 'cannot upload script %s to %s: %s; registered from: %s:%s', script_description(script), opt.upstream:get_addr():to_string(true), err, script.caller.short_src, script.caller.currentline) opt.upstream:fail() script.servers_ready[idx] = "failed" else -- Assume temporary error logger.infox(cfg, 'temporary error uploading script %s to %s: %s; registered from: %s:%s', script_description(script), opt.upstream:get_addr():to_string(true), err, script.caller.short_src, script.caller.currentline) script.servers_ready[idx] = "tempfail" end else opt.upstream:ok() logger.infox(cfg, "uploaded redis script %s to %s, sha: %s", script_description(script), opt.upstream:get_addr():to_string(true), data) script.sha = data -- We assume that sha is the same on all servers script.servers_ready[idx] = "done" end if is_any_server_ready(script) then script_set_loaded(script) elseif is_all_servers_failed(script) then script.pending_upload = false script.fatal_error = "cannot upload script to any server" end end local ret = rspamd_redis.make_request(opt) if not ret then logger.errx('cannot execute redis request to load script %s on %s', script_description(script), opt.upstream:get_addr()) script.servers_ready[idx] = "failed" opt.upstream:fail() end end if is_any_server_ready(script) then script_set_loaded(script) elseif is_all_servers_failed(script) then script.pending_upload = false script.fatal_error = "cannot upload script " .. script_description(script) .. " to any server" end end end local function load_redis_script(script, cfg, ev_base, _) if script.redis_params then load_script_taskless(script, cfg, ev_base) end end local function add_redis_script(script, redis_params, caller_level, maybe_filename) if not caller_level then caller_level = 2 end local caller = debug.getinfo(caller_level) or debug.getinfo(caller_level - 1) or E local new_script = { caller = caller, loaded = false, redis_params = redis_params, script = script, waitq = {}, -- callbacks pending for script being loaded id = #redis_scripts + 1, filename = maybe_filename, } -- Register on load function rspamd_config:add_on_load(function(cfg, ev_base, worker) local mult = 0.0 rspamd_config:add_periodic(ev_base, 0.0, function() if not new_script.sha and not new_script.fatal_error then load_redis_script(new_script, cfg, ev_base, worker) -- Do not wait for more than 10 seconds to retry if mult < 10 then mult = mult + 1 end return 1.0 * mult -- Check one more time in one second end -- All loaded, we are good to go return false end, false) end) table.insert(redis_scripts, new_script) return #redis_scripts end exports.add_redis_script = add_redis_script -- Loads a Redis script from a file, strips comments, and passes the content to -- `add_redis_script` function. -- -- @param filename The name of the file containing the Redis script. -- @param redis_params The Redis parameters to use for this script. -- @return The ID of the newly added Redis script. -- local function load_redis_script_from_file(filename, redis_params, dir) local lua_util = require "lua_util" if not dir then dir = rspamd_paths.LUALIBDIR end local path = filename if filename:sub(1, 1) ~= package.config:sub(1, 1) then -- Relative path path = lua_util.join_path(dir, "redis_scripts", filename) end -- Read file contents local file = io.open(path, "r") if not file then return nil, string.format("failed to open Redis script file: %s", path) end local script = file:read("*all") if not script then file:close() return nil, string.format("failed to read Redis script file: %s", path) end file:close() script = lua_util.strip_lua_comments(script) return add_redis_script(script, redis_params, 3, filename) end exports.load_redis_script_from_file = load_redis_script_from_file local function exec_redis_script(id, params, callback, keys, args) local redis_args = {} if not redis_scripts[id] then logger.errx("cannot find registered script with id %s", id) return false end local script = redis_scripts[id] if script.fatal_error then callback(script.fatal_error, nil) return true end if not script.redis_params then callback('no redis servers defined', nil) return true end local function do_call(can_reload) local function redis_cb(err, data) if not err then callback(err, data) elseif string.match(err, 'NOSCRIPT') then -- Schedule restart if possible if can_reload then table.insert(script.waitq, do_call) if not script.pending_upload then logger.infox(params.task or rspamd_config, 'redis script %s is not loaded (NOSCRIPT returned), scheduling reload', script_description(script)) script.sha = nil script.loaded = nil script.pending_upload = true -- We must initialize all servers as we don't know here which one failed for i, _ in ipairs(script.servers_ready) do script.servers_ready[i] = "unsent" end -- Reload scripts if this has not been initiated yet if params.task then load_script_task(script, params.task) else load_script_taskless(script, rspamd_config, params.ev_base) end else logger.infox(params.task or rspamd_config, 'redis script %s is not ready (NOSCRIPT returned), waiting it to be loaded', script_description(script)) end else callback(err, data) end else callback(err, data) end end if #redis_args == 0 then table.insert(redis_args, script.sha) table.insert(redis_args, tostring(#keys)) for _, k in ipairs(keys) do table.insert(redis_args, k) end if type(args) == 'table' then for _, a in ipairs(args) do table.insert(redis_args, a) end end end local redis_command = 'EVALSHA' if not params.is_write and script.redis_params.redis_version and script.redis_params.redis_version >= 7 then redis_command = 'EVALSHA_RO' end if params.task then if not rspamd_redis_make_request(params.task, script.redis_params, params.key, params.is_write, redis_cb, redis_command, redis_args) then callback('Cannot make redis request', nil) end else if not redis_make_request_taskless(params.ev_base, rspamd_config, script.redis_params, params.key, params.is_write, redis_cb, redis_command, redis_args) then callback('Cannot make redis request', nil) end end end if script.loaded and script.sha then do_call(true) else table.insert(script.waitq, do_call) if not script.servers_ready then -- Delayed until scripts are loaded logger.infox(params.task or rspamd_config, 'redis script %s is not loaded, trying to load', script_description(script)) -- Reload scripts if this has not been initiated yet if params.task then load_script_task(script, params.task) else load_script_taskless(script, rspamd_config, params.ev_base) end else -- Already loaded, but not yet ready logger.infox(params.task or rspamd_config, 'redis script %s is not ready, waiting it to be loaded', script_description(script)) end end return true end exports.exec_redis_script = exec_redis_script local function redis_connect_sync(redis_params, is_write, key, cfg, ev_base) if not redis_params then return false, nil end local rspamd_redis = require "rspamd_redis" local addr if key then if is_write then addr = redis_params['write_servers']:get_upstream_by_hash(key) else addr = redis_params['read_servers']:get_upstream_by_hash(key) end else if is_write then addr = redis_params['write_servers']:get_upstream_master_slave(key) else addr = redis_params['read_servers']:get_upstream_round_robin(key) end end if not addr then logger.errx(cfg or rspamd_config, 'cannot select redis server (all dead or pending DNS resolution)') return false, nil end local options = { host = addr:get_addr(), timeout = redis_params['timeout'], config = cfg or rspamd_config, ev_base = ev_base or rspamadm_ev_base, session = redis_params.session or rspamadm_session } for k, v in pairs(redis_params) do options[k] = v end if not options.config then logger.errx('config is not set') return false, nil, addr end if not options.ev_base then logger.errx('ev_base is not set') return false, nil, addr end if not options.session then logger.errx('session is not set') return false, nil, addr end local ret, conn = rspamd_redis.connect_sync(options) if not ret then logger.errx('cannot create redis connection: %s', conn) addr:fail() return false, nil, addr end if conn then local need_exec = false if redis_params['username'] then if redis_params['password'] then conn:add_cmd('AUTH', { redis_params['username'], redis_params['password'] }) need_exec = true else logger.warnx('Redis requires a password when username is supplied') return false, nil, addr end elseif redis_params['password'] then conn:add_cmd('AUTH', { redis_params['password'] }) need_exec = true end if redis_params['db'] then conn:add_cmd('SELECT', { tostring(redis_params['db']) }) need_exec = true elseif redis_params['dbname'] then conn:add_cmd('SELECT', { tostring(redis_params['dbname']) }) need_exec = true end if need_exec then local exec_ret, res = conn:exec() if not exec_ret then logger.errx('cannot prepare redis connection (authentication or db selection failure): %s', res) addr:fail() return false, nil, addr end end end return ret, conn, addr end exports.redis_connect_sync = redis_connect_sync --[[[ -- @function lua_redis.request(redis_params, attrs, req) -- Sends a request to Redis synchronously with coroutines or asynchronously using -- a callback (modern API) -- @param redis_params a table of redis server parameters -- @param attrs a table of redis request attributes (e.g. task, or ev_base + cfg + session) -- @param req a table of request: a command + command options -- @return {result,data/connection,address} boolean result, connection object in case of async request and results if using coroutines, redis server address --]] exports.request = function(redis_params, attrs, req) local lua_util = require "lua_util" if not attrs or not redis_params or not req then logger.errx('invalid arguments for redis request') return false, nil, nil end if not (attrs.task or (attrs.config and attrs.ev_base)) then logger.errx('invalid attributes for redis request') return false, nil, nil end local opts = lua_util.shallowcopy(attrs) local log_obj = opts.task or opts.config local addr if opts.callback then -- Wrap callback local callback = opts.callback local function rspamd_redis_make_request_cb(err, data) if err then addr:fail() else addr:ok() end callback(err, data, addr) end opts.callback = rspamd_redis_make_request_cb end local rspamd_redis = require "rspamd_redis" local is_write = opts.is_write if opts.key then if is_write then addr = redis_params['write_servers']:get_upstream_by_hash(attrs.key) else addr = redis_params['read_servers']:get_upstream_by_hash(attrs.key) end else if is_write then addr = redis_params['write_servers']:get_upstream_master_slave(attrs.key) else addr = redis_params['read_servers']:get_upstream_round_robin(attrs.key) end end if not addr then logger.errx(log_obj, 'cannot select redis server (all dead or pending DNS resolution)') return false, nil, nil end opts.host = addr:get_addr() opts.timeout = redis_params.timeout if type(req) == 'string' then opts.cmd = req else -- XXX: modifies the input table opts.cmd = table.remove(req, 1); opts.args = req end if redis_params.username then opts.username = redis_params.username end if redis_params.password then opts.password = redis_params.password end if redis_params.db then opts.dbname = redis_params.db end -- TLS options if redis_params.ssl ~= nil then opts.ssl = redis_params.ssl end if redis_params.no_ssl_verify ~= nil then opts.no_ssl_verify = redis_params.no_ssl_verify end if redis_params.ssl_ca then opts.ssl_ca = redis_params.ssl_ca end if redis_params.ssl_ca_dir then opts.ssl_ca_dir = redis_params.ssl_ca_dir end if redis_params.ssl_cert then opts.ssl_cert = redis_params.ssl_cert end if redis_params.ssl_key then opts.ssl_key = redis_params.ssl_key end if redis_params.sni then opts.sni = redis_params.sni end if opts.callback then lutil.debugm(N, 'perform generic async request to redis server' .. ' (host=%s, timeout=%s): cmd: %s, arguments: %s', addr, opts.timeout, opts.cmd, opts.args) local ret, conn = rspamd_redis.make_request(opts) if not ret then logger.errx(log_obj, 'cannot execute redis request') addr:fail() end return ret, conn, addr else -- Coroutines version lutil.debugm(N, 'perform generic coroutine request to redis server' .. ' (host=%s, timeout=%s): cmd: %s, arguments: %s', addr, opts.timeout, opts.cmd, opts.args) local ret, conn = rspamd_redis.connect_sync(opts) if not ret then logger.errx(log_obj, 'cannot execute redis request') addr:fail() else conn:add_cmd(opts.cmd, opts.args) return conn:exec() end return false, nil, addr end end --[[[ -- @function lua_redis.connect(redis_params, attrs) -- Connects to Redis synchronously with coroutines or asynchronously using a callback (modern API) -- @param redis_params a table of redis server parameters -- @param attrs a table of redis request attributes (e.g. task, or ev_base + cfg + session) -- @return {result,connection,address} boolean result, connection object, redis server address --]] exports.connect = function(redis_params, attrs) local lua_util = require "lua_util" if not attrs or not redis_params then logger.errx('invalid arguments for redis connect') return false, nil, nil end if not (attrs.task or (attrs.config and attrs.ev_base)) then logger.errx('invalid attributes for redis connect') return false, nil, nil end local opts = lua_util.shallowcopy(attrs) local log_obj = opts.task or opts.config local addr if opts.callback then -- Wrap callback local callback = opts.callback local function rspamd_redis_make_request_cb(err, data) if err then addr:fail() else addr:ok() end callback(err, data, addr) end opts.callback = rspamd_redis_make_request_cb end local rspamd_redis = require "rspamd_redis" local is_write = opts.is_write if opts.key then if is_write then addr = redis_params['write_servers']:get_upstream_by_hash(attrs.key) else addr = redis_params['read_servers']:get_upstream_by_hash(attrs.key) end else if is_write then addr = redis_params['write_servers']:get_upstream_master_slave(attrs.key) else addr = redis_params['read_servers']:get_upstream_round_robin(attrs.key) end end if not addr then logger.errx(log_obj, 'cannot select redis server (all dead or pending DNS resolution)') return false, nil, nil end opts.host = addr:get_addr() opts.timeout = redis_params.timeout if redis_params.username then opts.username = redis_params.username end if redis_params.password then opts.password = redis_params.password end if redis_params.db then opts.dbname = redis_params.db end -- TLS options if redis_params.ssl ~= nil then opts.ssl = redis_params.ssl end if redis_params.no_ssl_verify ~= nil then opts.no_ssl_verify = redis_params.no_ssl_verify end if redis_params.ssl_ca then opts.ssl_ca = redis_params.ssl_ca end if redis_params.ssl_ca_dir then opts.ssl_ca_dir = redis_params.ssl_ca_dir end if redis_params.ssl_cert then opts.ssl_cert = redis_params.ssl_cert end if redis_params.ssl_key then opts.ssl_key = redis_params.ssl_key end if redis_params.sni then opts.sni = redis_params.sni end if opts.callback then local ret, conn = rspamd_redis.connect(opts) if not ret then logger.errx(log_obj, 'cannot execute redis connect') addr:fail() end return ret, conn, addr else -- Coroutines version local ret, conn = rspamd_redis.connect_sync(opts) if not ret then logger.errx(log_obj, 'cannot execute redis connect') addr:fail() else return true, conn, addr end return false, nil, addr end end local redis_prefixes = {} --[[[ -- @function lua_redis.register_prefix(prefix, module, description[, optional]) -- Register new redis prefix for documentation purposes -- @param {string} prefix string prefix -- @param {string} module module name -- @param {string} description prefix description -- @param {table} optional optional kv pairs (e.g. pattern) --]] local function register_prefix(prefix, module, description, optional) local pr = { module = module, description = description } if optional and type(optional) == 'table' then for k, v in pairs(optional) do pr[k] = v end end redis_prefixes[prefix] = pr end exports.register_prefix = register_prefix --[[[ -- @function lua_redis.prefixes([mname]) -- Returns prefixes for specific module (or all prefixes). Returns a table prefix -> table --]] exports.prefixes = function(mname) if not mname then return redis_prefixes else local fun = require "fun" return fun.totable(fun.filter(function(_, data) return data.module == mname end, redis_prefixes)) end end -- Build new read/write upstream lists from a flattened sentinel topology -- (table of {[master_name] = { ip, port, slaves = {...} }}) and replace -- params.read_servers / params.write_servers in-place. Used by the -- synchronous rspamadm path; the async sentinel watcher in worker context -- has its own variant that also installs failure-monitor watchers. local function apply_sentinel_topology(params, masters) local read_servers_tbl, write_servers_tbl = {}, {} for _, master in pairs(masters) do write_servers_tbl[#write_servers_tbl + 1] = string.format( '%s:%s', master.ip, master.port) read_servers_tbl[#read_servers_tbl + 1] = string.format( '%s:%s', master.ip, master.port) for _, slave in ipairs(master.slaves or E) do if slave['master-link-status'] == 'ok' then read_servers_tbl[#read_servers_tbl + 1] = string.format( '%s:%s', slave.ip, slave.port) end end end if #write_servers_tbl == 0 then return false, 'no masters discovered from sentinel' end table.sort(read_servers_tbl) table.sort(write_servers_tbl) local read_servers_str = table.concat(read_servers_tbl, ',') local write_servers_str = table.concat(write_servers_tbl, ',') local upstream_list = require "rspamd_upstream_list" if read_servers_str ~= params.read_servers_str then local read_upstreams = upstream_list.create(rspamd_config, read_servers_str, 6379) if read_upstreams then logger.infox(rspamd_config, 'sentinel: replace read servers with new list: %s', read_servers_str) params.read_servers = read_upstreams params.read_servers_str = read_servers_str else return false, string.format('cannot parse read servers list: %s', read_servers_str) end end if write_servers_str ~= params.write_servers_str then local write_upstreams = upstream_list.create(rspamd_config, write_servers_str, 6379) if write_upstreams then logger.infox(rspamd_config, 'sentinel: replace write servers with new list: %s', write_servers_str) params.write_servers = write_upstreams params.write_servers_str = write_servers_str else return false, string.format('cannot parse write servers list: %s', write_servers_str) end end return true end -- Synchronously query Redis Sentinel via rspamd_redis.connect_sync to populate -- masters and slaves, then rewrite redis_params via apply_sentinel_topology. -- Walks the configured sentinel upstreams in round-robin order until one -- answers; only fails after exhausting them all. local function redis_resolve_sentinels_sync(redis_params, attrs) if not redis_params.sentinels then return true end local rspamd_redis = require "rspamd_redis" local sentinels = redis_params.sentinels local last_err = 'no sentinel upstreams to query' -- Try each sentinel until one succeeds; same upstream may be picked twice -- in a row depending on its weight, so we just bound by upstream count. local nsentinels = #(sentinels:all_upstreams() or E) if nsentinels == 0 then return false, 'sentinel upstream list is empty' end for _ = 1, nsentinels do local addr = sentinels:get_upstream_round_robin() if not addr then return false, last_err end local sentinel_addr = addr:get_addr() if not sentinel_addr then addr:fail() last_err = 'sentinel address not resolved' else local options = { host = sentinel_addr, timeout = attrs.timeout or redis_params.timeout or 1.0, config = attrs.config or rspamd_config, ev_base = attrs.ev_base, session = attrs.session, no_pool = true, } if redis_params.sentinel_username then options.username = redis_params.sentinel_username end if redis_params.sentinel_password then options.password = redis_params.sentinel_password end local masters, err = nil, nil local conn_ok, conn = rspamd_redis.connect_sync(options) if not conn_ok then err = string.format('cannot connect sentinel %s: %s', sentinel_addr:to_string(true), tostring(conn)) else conn:add_cmd('SENTINEL', { 'masters' }) local exec_ok, masters_result = conn:exec() if not exec_ok or type(masters_result) ~= 'table' then err = string.format('SENTINEL masters failed on %s: %s', sentinel_addr:to_string(true), tostring(masters_result)) else masters = {} for _, m in ipairs(masters_result) do local master = flatten_redis_table(m) if master.ip and master.ip:match(':') then master.ip = '[' .. master.ip .. ']' end if redis_params.sentinel_masters_pattern then if master.name and master.name:match(redis_params.sentinel_masters_pattern) then masters[master.name] = master else lutil.debugm(N, rspamd_config, 'skip master %s with ip %s and port %s, pattern %s', master.name, master.ip, master.port, redis_params.sentinel_masters_pattern) end else masters[master.name] = master end end end end if masters then local all_ok = true for name, master in pairs(masters) do master.slaves = {} local sok, sconn = rspamd_redis.connect_sync(options) if not sok then err = string.format('cannot connect sentinel for slaves on %s: %s', sentinel_addr:to_string(true), tostring(sconn)) all_ok = false break end sconn:add_cmd('SENTINEL', { 'slaves', name }) local s_eok, s_result = sconn:exec() if not s_eok or type(s_result) ~= 'table' then err = string.format('SENTINEL slaves %s failed on %s: %s', name, sentinel_addr:to_string(true), tostring(s_result)) all_ok = false break end for _, s in ipairs(s_result) do local slave = flatten_redis_table(s) if slave.ip and slave.ip:match(':') then slave.ip = '[' .. slave.ip .. ']' end master.slaves[#master.slaves + 1] = slave end end if all_ok then addr:ok() local ok, apply_err = apply_sentinel_topology(redis_params, masters) if ok then return true end return false, apply_err end end addr:fail() last_err = err or 'unknown sentinel failure' end end return false, last_err end -- Synchronously upload registered Redis scripts associated with redis_params. -- 'script_filter' is the value of opts.scripts: true (all matching scripts), -- false (skip), or an array of script ids returned from add_redis_script. local function redis_load_scripts_sync(redis_params, attrs, script_filter) if script_filter == false then return true end local id_filter if type(script_filter) == 'table' then id_filter = {} for _, id in ipairs(script_filter) do id_filter[id] = true end end local rspamd_redis = require "rspamd_redis" local errors = {} local matched = 0 for _, script in ipairs(redis_scripts) do if script.redis_params == redis_params and not script.fatal_error then if not id_filter or id_filter[script.id] then matched = matched + 1 local opts = prepare_redis_call(script) for idx, opt in ipairs(opts) do if not opt.skip and script.servers_ready[idx] ~= 'done' then local options = { host = opt.host, timeout = attrs.timeout or script.redis_params.timeout or 1.0, config = attrs.config or rspamd_config, ev_base = attrs.ev_base, session = attrs.session, } if opt.username then options.username = opt.username end if opt.password then options.password = opt.password end if opt.dbname then options.dbname = opt.dbname end local ok, conn = rspamd_redis.connect_sync(options) if not ok then opt.upstream:fail() script.servers_ready[idx] = 'failed' errors[#errors + 1] = string.format( 'cannot connect %s for SCRIPT LOAD %s: %s', opt.upstream:get_addr():to_string(true), script_description(script), tostring(conn)) else conn:add_cmd('SCRIPT', { 'LOAD', script.script }) local eok, sha = conn:exec() if not eok then opt.upstream:fail() script.servers_ready[idx] = 'failed' errors[#errors + 1] = string.format( 'SCRIPT LOAD %s failed on %s: %s', script_description(script), opt.upstream:get_addr():to_string(true), tostring(sha)) else opt.upstream:ok() script.sha = sha script.servers_ready[idx] = 'done' logger.infox(rspamd_config, 'uploaded redis script %s to %s, sha: %s', script_description(script), opt.upstream:get_addr():to_string(true), sha) end end end end if is_any_server_ready(script) then script_set_loaded(script) elseif is_all_servers_failed(script) then script.pending_upload = false script.fatal_error = 'cannot upload script to any server' end end end end if id_filter and matched == 0 then return false, 'no scripts matched the requested ids' end if #errors > 0 then -- Soft-fail: report errors but only fail if no script ended up loaded. local any_loaded = false for _, script in ipairs(redis_scripts) do if script.redis_params == redis_params and script.loaded then any_loaded = true break end end if not any_loaded and matched > 0 then return false, table.concat(errors, '; ') end end return true end local default_setup_opts = { sentinels = true, scripts = true, timeout = nil, } --[[[ -- @function lua_redis.prepare_redis_setup(redis_params[, opts], callback) -- One-shot synchronous initialization of a Redis connection for contexts -- where `rspamd_config:add_on_load` callbacks never fire (typically rspamadm -- tools). Performs, per `opts`, sentinel resolution and script loading, -- then invokes `callback(err)` once Redis is ready. -- -- @param {table} redis_params parsed redis params (from `parse_redis_server`). -- @param {table} opts (optional) merged via `lua_util.override_defaults` over: -- ``` -- { -- sentinels = true, -- resolve Sentinels if redis_params.sentinels is set -- scripts = true, -- true = all scripts bound to redis_params, -- -- false = skip script loading, -- -- { id1, id2, ... } = only those add_redis_script ids -- timeout = nil, -- override timeout (seconds) for setup IO -- ev_base = nil, -- defaults to rspamadm_ev_base -- session = nil, -- defaults to rspamadm_session -- config = nil, -- defaults to rspamd_config -- } -- ``` -- @param {function} callback `callback(err)` — `err` is `nil` on success or -- a string describing the failure. --]] exports.prepare_redis_setup = function(redis_params, opts, callback) if type(opts) == 'function' and callback == nil then callback = opts opts = nil end if type(callback) ~= 'function' then error('lua_redis.prepare_redis_setup: callback must be a function') end if not redis_params or type(redis_params) ~= 'table' then return callback('redis_params is nil or not a table') end opts = lutil.override_defaults(default_setup_opts, opts or {}) local attrs = { config = opts.config or rspamd_config, ev_base = opts.ev_base or rawget(_G, 'rspamadm_ev_base'), session = opts.session or rawget(_G, 'rspamadm_session'), timeout = opts.timeout, } if not attrs.ev_base then return callback('ev_base is not available; pass opts.ev_base or run from rspamadm') end if not attrs.session then return callback('async session is not available; pass opts.session or run from rspamadm') end if opts.sentinels and redis_params.sentinels then local ok, err = redis_resolve_sentinels_sync(redis_params, attrs) if not ok then return callback(err) end end if opts.scripts ~= false then local ok, err = redis_load_scripts_sync(redis_params, attrs, opts.scripts) if not ok then return callback(err) end end return callback(nil) end return exports
Save File
Cancel