Rapid spam filtering system https://rspamd.com/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

808 lines
22 KiB

  1. --[[
  2. Copyright (c) 2018, Vsevolod Stakhov <vsevolod@highsecure.ru>
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. ]]--
  13. local ansicolors = require "ansicolors"
  14. local local_conf = rspamd_paths['CONFDIR']
  15. local rspamd_util = require "rspamd_util"
  16. local rspamd_logger = require "rspamd_logger"
  17. local lua_util = require "lua_util"
  18. local lua_stat_tools = require "lua_stat"
  19. local lua_redis = require "lua_redis"
  20. local ucl = require "ucl"
  21. local argparse = require "argparse"
  22. local fun = require "fun"
  23. local plugins_stat = require "plugins_stats"
  24. local rspamd_logo = [[
  25. ____ _
  26. | _ \ ___ _ __ __ _ _ __ ___ __| |
  27. | |_) |/ __|| '_ \ / _` || '_ ` _ \ / _` |
  28. | _ < \__ \| |_) || (_| || | | | | || (_| |
  29. |_| \_\|___/| .__/ \__,_||_| |_| |_| \__,_|
  30. |_|
  31. ]]
  32. local parser = argparse()
  33. :name "rspamadm configwizard"
  34. :description "Perform guided configuration for Rspamd daemon"
  35. :help_description_margin(32)
  36. parser:option "-c --config"
  37. :description "Path to config file"
  38. :argname("<file>")
  39. :default(rspamd_paths["CONFDIR"] .. "/" .. "rspamd.conf")
  40. parser:argument "checks"
  41. :description "Checks to do (or 'list')"
  42. :argname("<checks>")
  43. :args "*"
  44. local redis_params
  45. local function printf(fmt, ...)
  46. if fmt then
  47. io.write(string.format(fmt, ...))
  48. end
  49. io.write('\n')
  50. end
  51. local function highlight(str)
  52. return ansicolors.white .. str .. ansicolors.reset
  53. end
  54. local function ask_yes_no(greet, default)
  55. local def_str
  56. if default then
  57. greet = greet .. "[Y/n]: "
  58. def_str = "yes"
  59. else
  60. greet = greet .. "[y/N]: "
  61. def_str = "no"
  62. end
  63. local reply = rspamd_util.readline(greet)
  64. if not reply then os.exit(0) end
  65. if #reply == 0 then reply = def_str end
  66. reply = reply:lower()
  67. if reply == 'y' or reply == 'yes' then return true end
  68. return false
  69. end
  70. local function readline_default(greet, def_value)
  71. local reply = rspamd_util.readline(greet)
  72. if not reply then os.exit(0) end
  73. if #reply == 0 then return def_value end
  74. return reply
  75. end
  76. local function readline_expire()
  77. local expire = '100d'
  78. repeat
  79. expire = readline_default("Expire time for new tokens [" .. expire .. "]: ",
  80. expire)
  81. expire = lua_util.parse_time_interval(expire)
  82. if not expire then
  83. expire = '100d'
  84. elseif expire > 2147483647 then
  85. printf("The maximum possible value is 2147483647 (about 68y)")
  86. expire = '68y'
  87. elseif expire < -1 then
  88. printf("The value must be a non-negative integer or -1")
  89. expire = -1
  90. elseif expire ~= math.floor(expire) then
  91. printf("The value must be an integer")
  92. expire = math.floor(expire)
  93. else
  94. return expire
  95. end
  96. until false
  97. end
  98. local function print_changes(changes)
  99. local function print_change(k, c, where)
  100. printf('File: %s, changes list:', highlight(local_conf .. '/'
  101. .. where .. '/'.. k))
  102. for ek,ev in pairs(c) do
  103. printf("%s => %s", highlight(ek), rspamd_logger.slog("%s", ev))
  104. end
  105. end
  106. for k, v in pairs(changes.l) do
  107. print_change(k, v, 'local.d')
  108. if changes.o[k] then
  109. v = changes.o[k]
  110. print_change(k, v, 'override.d')
  111. end
  112. print()
  113. end
  114. end
  115. local function apply_changes(changes)
  116. local function dirname(fname)
  117. if fname:match(".-/.-") then
  118. return string.gsub(fname, "(.*/)(.*)", "%1")
  119. else
  120. return nil
  121. end
  122. end
  123. local function apply_change(k, c, where)
  124. local fname = local_conf .. '/' .. where .. '/'.. k
  125. if not rspamd_util.file_exists(fname) then
  126. printf("Create file %s", highlight(fname))
  127. local dname = dirname(fname)
  128. if dname then
  129. local ret, err = rspamd_util.mkdir(dname, true)
  130. if not ret then
  131. printf("Cannot make directory %s: %s", dname, highlight(err))
  132. os.exit(1)
  133. end
  134. end
  135. end
  136. local f = io.open(fname, "a+")
  137. if not f then
  138. printf("Cannot open file %s, aborting", highlight(fname))
  139. os.exit(1)
  140. end
  141. f:write(ucl.to_config(c))
  142. f:close()
  143. end
  144. for k, v in pairs(changes.l) do
  145. apply_change(k, v, 'local.d')
  146. if changes.o[k] then
  147. v = changes.o[k]
  148. apply_change(k, v, 'override.d')
  149. end
  150. end
  151. end
  152. local function setup_controller(controller, changes)
  153. printf("Setup %s and controller worker:", highlight("WebUI"))
  154. if not controller.password or controller.password == 'q1' then
  155. if ask_yes_no("Controller password is not set, do you want to set one?", true) then
  156. local pw_encrypted = rspamadm.pw_encrypt()
  157. if pw_encrypted then
  158. printf("Set encrypted password to: %s", highlight(pw_encrypted))
  159. changes.l['worker-controller.inc'] = {
  160. password = pw_encrypted
  161. }
  162. end
  163. end
  164. end
  165. end
  166. local function setup_redis(cfg, changes)
  167. local function parse_servers(servers)
  168. local ls = lua_util.rspamd_str_split(servers, ",")
  169. return ls
  170. end
  171. printf("%s servers are not set:", highlight("Redis"))
  172. printf("The following modules will be enabled if you add Redis servers:")
  173. for k,_ in pairs(rspamd_plugins_state.disabled_redis) do
  174. printf("\t* %s", highlight(k))
  175. end
  176. if ask_yes_no("Do you wish to set Redis servers?", true) then
  177. local read_servers = readline_default("Input read only servers separated by `,` [default: localhost]: ",
  178. "localhost")
  179. local rs = parse_servers(read_servers)
  180. if rs and #rs > 0 then
  181. changes.l['redis.conf'] = {
  182. read_servers = table.concat(rs, ",")
  183. }
  184. end
  185. local write_servers = readline_default("Input write only servers separated by `,` [default: "
  186. .. read_servers .. "]: ", read_servers)
  187. if not write_servers or #write_servers == 0 then
  188. printf("Use read servers %s as write servers", highlight(table.concat(rs, ",")))
  189. write_servers = read_servers
  190. end
  191. redis_params = {
  192. read_servers = rs,
  193. }
  194. local ws = parse_servers(write_servers)
  195. if ws and #ws > 0 then
  196. changes.l['redis.conf']['write_servers'] = table.concat(ws, ",")
  197. redis_params['write_servers'] = ws
  198. end
  199. if ask_yes_no('Do you have any password set for your Redis?') then
  200. local passwd = readline_default("Enter Redis password:", nil)
  201. if passwd then
  202. changes.l['redis.conf']['password'] = passwd
  203. redis_params['password'] = passwd
  204. end
  205. end
  206. if ask_yes_no('Do you have any specific database for your Redis?') then
  207. local db = readline_default("Enter Redis database:", nil)
  208. if db then
  209. changes.l['redis.conf']['db'] = db
  210. redis_params['db'] = db
  211. end
  212. end
  213. end
  214. end
  215. local function setup_dkim_signing(cfg, changes)
  216. -- Remove the trailing slash of a pathname, if present.
  217. local function remove_trailing_slash(path)
  218. if string.sub(path, -1) ~= "/" then return path end
  219. return string.sub(path, 1, string.len(path) - 1)
  220. end
  221. printf('How would you like to set up DKIM signing?')
  222. printf('1. Use domain from %s for sign', highlight('mime from header'))
  223. printf('2. Use domain from %s for sign', highlight('SMTP envelope from'))
  224. printf('3. Use domain from %s for sign', highlight('authenticated user'))
  225. printf('4. Sign all mail from %s', highlight('specific networks'))
  226. printf()
  227. local sign_type = readline_default('Enter your choice (1, 2, 3, 4) [default: 1]: ', '1')
  228. local sign_networks
  229. local allow_mismatch
  230. local auth_only
  231. local use_esld
  232. local sign_domain = 'pet luacheck'
  233. local defined_auth_types = {'header', 'envelope', 'auth', 'recipient'}
  234. if sign_type == '4' then
  235. repeat
  236. sign_networks = readline_default('Enter list of networks to perform dkim signing: ',
  237. '')
  238. until #sign_networks ~= 0
  239. sign_networks = fun.totable(fun.map(lua_util.rspamd_str_trim,
  240. lua_util.str_split(sign_networks, ',; ')))
  241. printf('What domain would you like to use for signing?')
  242. printf('* %s to use mime from domain', highlight('header'))
  243. printf('* %s to use SMTP from domain', highlight('envelope'))
  244. printf('* %s to use domain from SMTP auth', highlight('auth'))
  245. printf('* %s to use domain from SMTP recipient', highlight('recipient'))
  246. printf('* anything else to use as a %s domain (e.g. `example.com`)', highlight('static'))
  247. printf()
  248. sign_domain = readline_default('Enter your choice [default: header]: ', 'header')
  249. else
  250. if sign_type == '1' then
  251. sign_domain = 'header'
  252. elseif sign_type == '2' then
  253. sign_domain = 'envelope'
  254. else
  255. sign_domain = 'auth'
  256. end
  257. end
  258. if sign_type ~= '3' then
  259. auth_only = ask_yes_no(
  260. string.format('Do you want to sign mail from %s only? ',
  261. highlight('authenticated users')), true)
  262. else
  263. auth_only = true
  264. end
  265. if fun.any(function(s) return s == sign_domain end, defined_auth_types) then
  266. -- Allow mismatch
  267. allow_mismatch = ask_yes_no(
  268. string.format('Allow data %s, e.g. if mime from domain is not equal to authenticated user domain? ',
  269. highlight('mismatch')), true)
  270. -- ESLD check
  271. use_esld = ask_yes_no(
  272. string.format('Do you want to use %s domain (e.g. example.com instead of foo.example.com)? ',
  273. highlight('effective')), true)
  274. else
  275. allow_mismatch = true
  276. end
  277. local domains = {}
  278. local has_domains = false
  279. local dkim_keys_dir = rspamd_paths["DBDIR"] .. "/dkim/"
  280. local prompt = string.format("Enter output directory for the keys [default: %s]: ",
  281. highlight(dkim_keys_dir))
  282. dkim_keys_dir = remove_trailing_slash(readline_default(prompt, dkim_keys_dir))
  283. local ret, err = rspamd_util.mkdir(dkim_keys_dir, true)
  284. if not ret then
  285. printf("Cannot make directory %s: %s", dkim_keys_dir, highlight(err))
  286. os.exit(1)
  287. end
  288. local function print_domains()
  289. printf("Domains configured:")
  290. for k,v in pairs(domains) do
  291. printf("Domain: %s, selector: %s, privkey: %s", highlight(k),
  292. v.selector, v.privkey)
  293. end
  294. printf("--")
  295. end
  296. repeat
  297. if has_domains then
  298. print_domains()
  299. end
  300. local domain
  301. repeat
  302. domain = rspamd_util.readline("Enter domain to sign: ")
  303. if not domain then
  304. os.exit(1)
  305. end
  306. until #domain ~= 0
  307. local selector = readline_default("Enter selector [default: dkim]: ", 'dkim')
  308. if not selector then selector = 'dkim' end
  309. local privkey_file = string.format("%s/%s.%s.key", dkim_keys_dir, domain,
  310. selector)
  311. if not rspamd_util.file_exists(privkey_file) then
  312. if ask_yes_no("Do you want to create privkey " .. highlight(privkey_file),
  313. true) then
  314. local pubkey_file = privkey_file .. ".pub"
  315. rspamadm.dkim_keygen(domain, selector, privkey_file, pubkey_file, 2048)
  316. local f = io.open(pubkey_file)
  317. if not f then
  318. printf("Cannot open pubkey file %s, fatal error", highlight(pubkey_file))
  319. os.exit(1)
  320. end
  321. local content = f:read("*all")
  322. f:close()
  323. print("To make dkim signing working, you need to place the following record in your DNS zone:")
  324. print(content)
  325. end
  326. end
  327. domains[domain] = {
  328. selector = selector,
  329. path = privkey_file,
  330. }
  331. until not ask_yes_no("Do you wish to add another DKIM domain?")
  332. changes.l['dkim_signing.conf'] = {domain = domains}
  333. local res_tbl = changes.l['dkim_signing.conf']
  334. if sign_networks then
  335. res_tbl.sign_networks = sign_networks
  336. res_tbl.use_domain_sign_networks = sign_domain
  337. else
  338. res_tbl.use_domain = sign_domain
  339. end
  340. if allow_mismatch then
  341. res_tbl.allow_hdrfrom_mismatch = true
  342. res_tbl.allow_hdrfrom_mismatch_sign_networks = true
  343. res_tbl.allow_username_mismatch = true
  344. end
  345. res_tbl.use_esld = use_esld
  346. res_tbl.auth_only = auth_only
  347. end
  348. local function check_redis_classifier(cls, changes)
  349. local symbol_spam, symbol_ham
  350. -- Load symbols from statfiles
  351. local statfiles = cls.statfile
  352. for _,stf in ipairs(statfiles) do
  353. local symbol = stf.symbol or 'undefined'
  354. local spam
  355. if stf.spam then
  356. spam = stf.spam
  357. else
  358. if string.match(symbol:upper(), 'SPAM') then
  359. spam = true
  360. else
  361. spam = false
  362. end
  363. end
  364. if spam then
  365. symbol_spam = symbol
  366. else
  367. symbol_ham = symbol
  368. end
  369. end
  370. if not symbol_spam or not symbol_ham then
  371. printf("Calssifier has no symbols defined")
  372. return
  373. end
  374. local parsed_redis = {}
  375. if not lua_redis.try_load_redis_servers(cls, nil, parsed_redis) then
  376. if not lua_redis.try_load_redis_servers(redis_params, nil, parsed_redis) then
  377. printf("Cannot parse Redis params")
  378. return
  379. end
  380. end
  381. local function try_convert(update_config)
  382. if ask_yes_no("Do you wish to convert data to the new schema?", true) then
  383. local expire = readline_expire()
  384. if not lua_stat_tools.convert_bayes_schema(parsed_redis, symbol_spam,
  385. symbol_ham, expire) then
  386. printf("Conversion failed")
  387. else
  388. printf("Conversion succeed")
  389. if update_config then
  390. changes.l['classifier-bayes.conf'] = {
  391. new_schema = true,
  392. }
  393. if expire then
  394. changes.l['classifier-bayes.conf'].expire = expire
  395. end
  396. end
  397. end
  398. end
  399. end
  400. local function get_version(conn)
  401. conn:add_cmd("SMEMBERS", {"RS_keys"})
  402. local ret,members = conn:exec()
  403. -- Empty db
  404. if not ret or #members == 0 then return false,0 end
  405. -- We still need to check versions
  406. local lua_script = [[
  407. local ver = 0
  408. local tst = redis.call('GET', KEYS[1]..'_version')
  409. if tst then
  410. ver = tonumber(tst) or 0
  411. end
  412. return ver
  413. ]]
  414. conn:add_cmd('EVAL', {lua_script, '1', 'RS'})
  415. local _,ver = conn:exec()
  416. return true,tonumber(ver)
  417. end
  418. local function check_expire(conn)
  419. -- We still need to check versions
  420. local lua_script = [[
  421. local ttl = 0
  422. local sc = redis.call('SCAN', 0, 'MATCH', 'RS*_*', 'COUNT', 1)
  423. local _,key = sc[1], sc[2]
  424. if key and key[1] then
  425. ttl = redis.call('TTL', key[1])
  426. end
  427. return ttl
  428. ]]
  429. conn:add_cmd('EVAL', {lua_script, '0'})
  430. local _,ttl = conn:exec()
  431. return tonumber(ttl)
  432. end
  433. local res,conn = lua_redis.redis_connect_sync(parsed_redis, true)
  434. if not res then
  435. printf("Cannot connect to Redis server")
  436. return false
  437. end
  438. if not cls.new_schema then
  439. local r,ver = get_version(conn)
  440. if not r then return false end
  441. if ver ~= 2 then
  442. if not ver then
  443. printf('Key "RS_version" has not been found in Redis for %s/%s',
  444. symbol_ham, symbol_spam)
  445. else
  446. printf("You are using an old schema version: %s for %s/%s",
  447. ver, symbol_ham, symbol_spam)
  448. end
  449. try_convert(true)
  450. else
  451. printf("You have configured an old schema for %s/%s but your data has new layout",
  452. symbol_ham, symbol_spam)
  453. if ask_yes_no("Switch config to the new schema?", true) then
  454. changes.l['classifier-bayes.conf'] = {
  455. new_schema = true,
  456. }
  457. local expire = check_expire(conn)
  458. if expire then
  459. changes.l['classifier-bayes.conf'].expire = expire
  460. end
  461. end
  462. end
  463. else
  464. local r,ver = get_version(conn)
  465. if not r then return false end
  466. if ver ~= 2 then
  467. printf("You have configured new schema for %s/%s but your DB has old version: %s",
  468. symbol_spam, symbol_ham, ver)
  469. try_convert(false)
  470. else
  471. printf(
  472. 'You have configured new schema for %s/%s and your DB already has new layout (v. %s).' ..
  473. ' DB conversion is not needed.',
  474. symbol_spam, symbol_ham, ver)
  475. end
  476. end
  477. end
  478. local function setup_statistic(cfg, changes)
  479. local sqlite_configs = lua_stat_tools.load_sqlite_config(cfg)
  480. if #sqlite_configs > 0 then
  481. if not redis_params then
  482. printf('You have %d sqlite classifiers, but you have no Redis servers being set',
  483. #sqlite_configs)
  484. return false
  485. end
  486. local parsed_redis = {}
  487. if lua_redis.try_load_redis_servers(redis_params, nil, parsed_redis) then
  488. printf('You have %d sqlite classifiers', #sqlite_configs)
  489. local expire = readline_expire()
  490. local reset_previous = ask_yes_no("Reset previous data?")
  491. if ask_yes_no('Do you wish to convert them to Redis?', true) then
  492. for _,cls in ipairs(sqlite_configs) do
  493. if rspamd_util.file_exists(cls.db_spam) and rspamd_util.file_exists(cls.db_ham) then
  494. if not lua_stat_tools.convert_sqlite_to_redis(parsed_redis, cls.db_spam,
  495. cls.db_ham, cls.symbol_spam, cls.symbol_ham, cls.learn_cache, expire,
  496. reset_previous) then
  497. rspamd_logger.errx('conversion failed')
  498. return false
  499. end
  500. else
  501. rspamd_logger.messagex('cannot find %s and %s, skip conversion',
  502. cls.db_spam, cls.db_ham)
  503. end
  504. rspamd_logger.messagex('Converted classifier to the from sqlite to redis')
  505. changes.l['classifier-bayes.conf'] = {
  506. backend = 'redis',
  507. new_schema = true,
  508. }
  509. if expire then
  510. changes.l['classifier-bayes.conf'].expire = expire
  511. end
  512. if cls.learn_cache then
  513. changes.l['classifier-bayes.conf'].cache = {
  514. backend = 'redis'
  515. }
  516. end
  517. end
  518. end
  519. end
  520. else
  521. -- Check sanity for the existing Redis classifiers
  522. local classifier = cfg.classifier
  523. if classifier then
  524. if classifier[1] then
  525. for _,cls in ipairs(classifier) do
  526. if cls.bayes then cls = cls.bayes end
  527. if cls.backend and cls.backend == 'redis' then
  528. check_redis_classifier(cls, changes)
  529. end
  530. end
  531. else
  532. if classifier.bayes then
  533. classifier = classifier.bayes
  534. if classifier[1] then
  535. for _,cls in ipairs(classifier) do
  536. if cls.backend and cls.backend == 'redis' then
  537. check_redis_classifier(cls, changes)
  538. end
  539. end
  540. else
  541. if classifier.backend and classifier.backend == 'redis' then
  542. check_redis_classifier(classifier, changes)
  543. end
  544. end
  545. end
  546. end
  547. end
  548. end
  549. end
  550. local function find_worker(cfg, wtype)
  551. if cfg.worker then
  552. for k,s in pairs(cfg.worker) do
  553. if type(k) == 'number' and type(s) == 'table' then
  554. if s[wtype] then return s[wtype] end
  555. end
  556. if type(s) == 'table' and s.type and s.type == wtype then
  557. return s
  558. end
  559. if type(k) == 'string' and k == wtype then return s end
  560. end
  561. end
  562. return nil
  563. end
  564. return {
  565. handler = function(cmd_args)
  566. local changes = {
  567. l = {}, -- local changes
  568. o = {}, -- override changes
  569. }
  570. local interactive_start = true
  571. local checks = {}
  572. local all_checks = {
  573. 'controller',
  574. 'redis',
  575. 'dkim',
  576. 'statistic',
  577. }
  578. local opts = parser:parse(cmd_args)
  579. local args = opts['checks'] or {}
  580. local _r,err = rspamd_config:load_ucl(opts['config'])
  581. local cfg = rspamd_config:get_ucl()
  582. if not _r then
  583. rspamd_logger.errx('cannot parse %s: %s', opts['config'], err)
  584. os.exit(1)
  585. end
  586. _r,err = rspamd_config:parse_rcl({'logging', 'worker'})
  587. if not _r then
  588. rspamd_logger.errx('cannot process %s: %s', opts['config'], err)
  589. os.exit(1)
  590. end
  591. if not rspamd_config:init_modules() then
  592. rspamd_logger.errx('cannot init modules when parsing %s', opts['config'])
  593. os.exit(1)
  594. end
  595. if #args > 0 then
  596. interactive_start = false
  597. for _,arg in ipairs(args) do
  598. if arg == 'all' then
  599. checks = all_checks
  600. elseif arg == 'list' then
  601. printf(highlight(rspamd_logo))
  602. printf('Available modules')
  603. for _,c in ipairs(all_checks) do
  604. printf('- %s', c)
  605. end
  606. return
  607. else
  608. table.insert(checks, arg)
  609. end
  610. end
  611. else
  612. checks = all_checks
  613. end
  614. local function has_check(check)
  615. for _,c in ipairs(checks) do
  616. if c == check then
  617. return true
  618. end
  619. end
  620. return false
  621. end
  622. rspamd_util.umask('022')
  623. if interactive_start then
  624. printf(highlight(rspamd_logo))
  625. printf("Welcome to the configuration tool")
  626. printf("We use %s configuration file, writing results to %s",
  627. highlight(opts['config']), highlight(local_conf))
  628. plugins_stat(nil, nil)
  629. end
  630. if not interactive_start or
  631. ask_yes_no("Do you wish to continue?", true) then
  632. if has_check('controller') then
  633. local controller = find_worker(cfg, 'controller')
  634. if controller then
  635. setup_controller(controller, changes)
  636. end
  637. end
  638. if has_check('redis') then
  639. if not cfg.redis or (not cfg.redis.servers and not cfg.redis.read_servers) then
  640. setup_redis(cfg, changes)
  641. else
  642. redis_params = cfg.redis
  643. end
  644. else
  645. redis_params = cfg.redis
  646. end
  647. if has_check('dkim') then
  648. if cfg.dkim_signing and not cfg.dkim_signing.domain then
  649. if ask_yes_no('Do you want to setup dkim signing feature?') then
  650. setup_dkim_signing(cfg, changes)
  651. end
  652. end
  653. end
  654. if has_check('statistic') or has_check('statistics') then
  655. setup_statistic(cfg, changes)
  656. end
  657. local nchanges = 0
  658. for _,_ in pairs(changes.l) do nchanges = nchanges + 1 end
  659. for _,_ in pairs(changes.o) do nchanges = nchanges + 1 end
  660. if nchanges > 0 then
  661. print_changes(changes)
  662. if ask_yes_no("Apply changes?", true) then
  663. apply_changes(changes)
  664. printf("%d changes applied, the wizard is finished now", nchanges)
  665. printf("*** Please reload the Rspamd configuration ***")
  666. else
  667. printf("No changes applied, the wizard is finished now")
  668. end
  669. else
  670. printf("No changes found, the wizard is finished now")
  671. end
  672. end
  673. end,
  674. name = 'configwizard',
  675. description = parser._description,
  676. }