← Back
Editing: neural_export.lua
--[[ Copyright (c) 2026, 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. ]]-- --[[ Export neural network model from Redis to RNM1 format for distribution. This tool extracts a trained neural model from Redis and saves it as a portable msgpack file that can be loaded by other rspamd instances. Usage: rspamadm neural_export -o model.rnm Options: -c, --config Path to rspamd config (to get Redis connection) -r, --rule Rule name to export (default: 'default') -s, --settings Settings ID to export (default: 'default') -o, --output Output file path (required) -v, --version Specific model version to export (default: latest) --list List available models instead of exporting --digest Also print providers digest for URL construction ]]-- local rspamd_logger = require "rspamd_logger" local lua_redis = require "lua_redis" local argparse = require "argparse" local ucl = require "ucl" local parser = argparse() :name "rspamadm neural_export" :description "Export neural network model from Redis to RNM1 format" :help_description_margin(32) parser:option "-c --config" :description "Path to config file" :argname("<cfg>") :default(rspamd_paths["CONFDIR"] .. "/" .. "rspamd.conf") parser:option "-r --rule" :description "Rule name to export" :argname("<rule>") :default("default") parser:option "-s --settings" :description "Settings ID to export" :argname("<id>") :default("default") parser:option "-o --output" :description "Output file path" :argname("<file>") parser:option "-v --version" :description "Specific model version to export" :argname("<version>") :convert(tonumber) parser:flag "--list" :description "List available models instead of exporting" parser:flag "--digest" :description "Print providers digest for URL construction" -- Model format constants (must match lua_neural_external) local MODEL_MAGIC = "RNM1" local MODEL_FORMAT_VERSION = 1 --- Load config and initialize Redis connection local function init_redis(opts) local _r, err = rspamd_config:load_ucl(opts['config']) if not _r then rspamd_logger.errx('cannot parse %s: %s', opts['config'], err) return nil end _r, err = rspamd_config:parse_rcl({ 'logging', 'worker' }) if not _r then rspamd_logger.errx('cannot process %s: %s', opts['config'], err) return nil end local redis_params = lua_redis.parse_redis_server('neural') if not redis_params then rspamd_logger.errx('cannot get Redis configuration for neural module') return nil end return redis_params end --- Get neural rule configuration local function get_neural_rule(opts) local neural_opts = rspamd_config:get_all_opt('neural') if not neural_opts then rspamd_logger.errx('no neural configuration found') return nil end local rules = neural_opts['rules'] or {} local rule_name = opts['rule'] -- Handle legacy config (neural_opts itself is the rule) if not rules['default'] and neural_opts.train then rules['default'] = neural_opts end local rule = rules[rule_name] if not rule then rspamd_logger.errx('rule "%s" not found in neural config', rule_name) return nil end if not rule.prefix then rule.prefix = rule_name end if not rule.name then rule.name = rule_name end return rule end --- Build Redis prefix for a rule/settings combination local function build_redis_prefix(rule, settings_name) local neural_common = require "plugins/neural" return neural_common.redis_ann_prefix({ prefix = rule.prefix or 'default' }, settings_name) end local function redis_connect(redis_params) local redis_opts = {} for k, v in pairs(redis_params) do redis_opts[k] = v end redis_opts.opaque_data = true local ok, conn = lua_redis.redis_connect_sync(redis_opts, false) if not ok then return nil, conn end return conn end local function maybe_convert_text(data) if type(data) == 'userdata' or (type(data) == 'table' and data.cookie) then return tostring(data) end return data end --- List available models from Redis local function list_models(conn, prefix) local ok, err = conn:add_cmd('ZREVRANGE', { prefix, '0', '-1', 'WITHSCORES' }) if not ok then return nil, err end local ret, data = conn:exec() if not ret then return nil, data end return data end local function profile_has_ann(conn, ann_key) local ok, err = conn:add_cmd('HEXISTS', { ann_key, 'ann' }) if not ok then return false, err end local ret, data = conn:exec() if not ret then return false, data end return tonumber(data) == 1, nil end --- Load model data from Redis local function load_model_from_redis(conn, ann_key) local ok, err = conn:add_cmd('HMGET', { ann_key, 'ann', 'roc_thresholds', 'pca', 'providers_meta', 'norm_stats' }) if not ok then return nil, err end local ret, data = conn:exec() if not ret then return nil, data end if type(data) ~= 'table' then return nil, 'no data found at key: ' .. ann_key end -- data[1] = ann, data[2] = roc_thresholds, data[3] = pca, -- data[4] = providers_meta, data[5] = norm_stats local result = { ann_data = maybe_convert_text(data[1]), roc_thresholds = maybe_convert_text(data[2]), pca_data = maybe_convert_text(data[3]), providers_meta = maybe_convert_text(data[4]), norm_stats = maybe_convert_text(data[5]), } if result.roc_thresholds then local roc_parser = ucl.parser() local parse_ok = roc_parser:parse_text(result.roc_thresholds) if parse_ok then result.roc_thresholds = roc_parser:get_object() end end if result.norm_stats then local norm_parser = ucl.parser() local parse_ok = norm_parser:parse_text(result.norm_stats) if parse_ok then result.norm_stats = norm_parser:get_object() end end return result end --- Load profile from Redis sorted set local function load_profile(profile_str) local profile_parser = ucl.parser() local ok = profile_parser:parse_string(profile_str) if not ok then return nil end return profile_parser:get_object() end --- Serialize model to RNM1 format local function serialize_model(model_data, providers_digest, opts) opts = opts or {} local model = { magic = MODEL_MAGIC, version = MODEL_FORMAT_VERSION, model_version = opts.model_version or 1, providers_digest = providers_digest, ann_data = model_data.ann_data, created_at = os.time(), } if model_data.pca_data then model.pca_data = model_data.pca_data end if opts.norm_stats then model.norm_stats = opts.norm_stats end if opts.roc_thresholds then model.roc_thresholds = opts.roc_thresholds end return ucl.to_format(model, 'msgpack') end local function handler(args) local opts = parser:parse(args) -- Initialize local redis_params = init_redis(opts) if not redis_params then os.exit(1) end local rule = get_neural_rule(opts) if not rule then os.exit(1) end -- Get providers digest local neural_common = require "plugins/neural" local providers_digest = neural_common.providers_config_digest(rule.providers, rule) -- Print digest if requested if opts['digest'] then print(string.format("Providers digest: %s", providers_digest)) if not opts['list'] and not opts['output'] then return end end local settings_name = opts['settings'] local prefix = build_redis_prefix(rule, settings_name) local conn, conn_err = redis_connect(redis_params) if not conn then rspamd_logger.errx('cannot connect to redis server: %s', conn_err or 'unknown error') os.exit(1) end -- List mode: show available models if opts['list'] then print(string.format("\nAvailable models for rule '%s', settings '%s':", opts['rule'], settings_name)) print(string.format("Redis prefix: %s", prefix)) print("") local data, err = list_models(conn, prefix) if err then rspamd_logger.errx('failed to list models: %s', err) os.exit(1) end if not data or #data == 0 then print("No models found") return end for i = 1, #data, 2 do local profile_str = data[i] local score = tonumber(data[i + 1]) or 0 local profile = load_profile(profile_str) if profile then local has_ann = false if profile.redis_key then has_ann = profile_has_ann(conn, profile.redis_key) end local ts = os.date("%Y-%m-%d %H:%M:%S", score) print(string.format(" Version: %s, Key: %s, Updated: %s%s", profile.version or 0, profile.redis_key or "?", ts, has_ann and '' or ' [stale]')) end end return end -- Export mode: need output file if not opts['output'] then rspamd_logger.errx('output file is required for export (use -o <file>)') os.exit(1) end local output_file = opts['output'] local target_version = opts['version'] local data, err = list_models(conn, prefix) if err then rspamd_logger.errx('failed to list models: %s', err) os.exit(1) end if not data or #data == 0 then rspamd_logger.errx('no models found in Redis') os.exit(1) end local selected_profile local selected_score = 0 for i = 1, #data, 2 do local profile_str = data[i] local score = tonumber(data[i + 1]) or 0 local profile = load_profile(profile_str) if profile then local has_ann = profile.redis_key and profile_has_ann(conn, profile.redis_key) if not has_ann then rspamd_logger.warnx('skip stale model profile: version=%s key=%s', profile.version or 0, profile.redis_key or '?') elseif target_version then if profile.version == target_version then selected_profile = profile selected_score = score break end elseif not selected_profile or score > selected_score then selected_profile = profile selected_score = score end end end if not selected_profile then if target_version then rspamd_logger.errx('model version %s not found', target_version) else rspamd_logger.errx('no suitable model found') end os.exit(1) end local ann_key = selected_profile.redis_key rspamd_logger.messagex('Loading model from key: %s (version %s)', ann_key, selected_profile.version or 0) local model_data, load_err = load_model_from_redis(conn, ann_key) if load_err then rspamd_logger.errx('failed to load model data: %s', load_err) os.exit(1) end if not model_data.ann_data then rspamd_logger.errx('failed to load ANN data from Redis') os.exit(1) end local rnm_data = serialize_model(model_data, providers_digest, { model_version = selected_profile.version or 1, norm_stats = model_data.norm_stats, roc_thresholds = model_data.roc_thresholds, }) local out, open_err = io.open(output_file, 'wb') if not out then rspamd_logger.errx('cannot open output file %s: %s', output_file, open_err) os.exit(1) end local write_ok, write_err = out:write(rnm_data) out:close() if not write_ok then rspamd_logger.errx('cannot write output file %s: %s', output_file, write_err) os.exit(1) end rspamd_logger.messagex('Exported model to: %s', output_file) rspamd_logger.messagex(' Model version: %s', selected_profile.version or 0) rspamd_logger.messagex(' Providers digest: %s', providers_digest:sub(1, 16) .. '...') rspamd_logger.messagex(' File size: %s bytes', #rnm_data) end return { name = "neural_export", aliases = { "neural_export" }, handler = handler, description = parser._description }
Save File
Cancel