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