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