1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-04-03 00:07:05 +00:00
mailinabox/management/reporting/capture/capture.py
2022-09-19 14:45:11 -04:00

332 lines
9.7 KiB
Python
Executable File

#!/usr/bin/env python3
#####
##### This file is part of Mail-in-a-Box-LDAP which is released under the
##### terms of the GNU Affero General Public License as published by the
##### Free Software Foundation, either version 3 of the License, or (at
##### your option) any later version. See file LICENSE or go to
##### https://github.com/downtownallday/mailinabox-ldap for full license
##### details.
#####
from logs.TailFile import TailFile
from mail.PostfixLogHandler import PostfixLogHandler
from mail.DovecotLogHandler import DovecotLogHandler
from logs.ReadPositionStoreInFile import ReadPositionStoreInFile
from db.SqliteConnFactory import SqliteConnFactory
from db.SqliteEventStore import SqliteEventStore
from db.Pruner import Pruner
from util.env import load_env_vars_from_file
import time
import logging, logging.handlers
import json
import re
import os
import sys
import signal
log = logging.getLogger(__name__)
if os.path.exists('/etc/mailinabox.conf'):
env = load_env_vars_from_file('/etc/mailinabox.conf')
else:
env = { 'STORAGE_ROOT': os.environ.get('STORAGE_ROOT', os.getcwd()) }
CAPTURE_STORAGE_ROOT = os.path.join(env['STORAGE_ROOT'], 'reporting')
# default configuration, if not specified or
# CAPTURE_STORAGE_ROOT/config.json does not exist
config_default = {
'default_config': True,
'capture': True,
'prune_policy': {
'frequency_min': 2400,
'older_than_days': 30
},
'drop_disposition': {
'failed_login_attempt': True,
'suspected_scanner': False,
'reject': True
}
}
config_default_file = os.path.join(CAPTURE_STORAGE_ROOT, 'config.json')
# options
options = {
'daemon': True,
'log_level': logging.WARNING,
'log_file': "/var/log/mail.log",
'stop_at_eof': False,
'pos_file': "/var/lib/mailinabox/capture-pos.json",
'sqlite_file': os.path.join(CAPTURE_STORAGE_ROOT, 'capture.sqlite'),
'working_dir': "/var/run/mailinabox",
'config': config_default,
'_config_file': False, # absolute path if "config" was from a file
'_runtime_config_file': "runtime_config.json" # relative to working_dir
}
def read_config_file(file, throw=False):
try:
with open(file) as fp:
newconfig = json.loads(fp.read())
newconfig['from'] = { 'type':'file', 'location':file }
return newconfig
except FileNotFoundError as e:
if not throw:
return False
raise e
def write_config(tofile, config):
d = os.path.dirname(tofile)
if d and not os.path.exists(d):
os.mkdir(d, mode=0o770)
with open(tofile, "w") as fp:
fp.write(json.dumps(config))
def usage():
print('usage: %s [options]' % sys.argv[0])
sys.exit(1)
def process_cmdline(options):
argi = 1
marg = 0
while argi < len(sys.argv):
arg = sys.argv[argi]
have_next = ( argi+1 < len(sys.argv) )
if arg=='-d':
options['daemon'] = False
elif arg=='-loglevel' and have_next:
argi += 1
arg = sys.argv[argi].lower()
if arg=='info':
options['log_level'] = logging.INFO
elif arg=='warning':
options['log_level'] = logging.WARNING
elif arg=='debug':
options['log_level'] = logging.DEBUG
elif arg=='error':
options['log_level'] = logging.ERROR
else:
sys.stderr.write('unknown log level "%s"\n' % sys.argv[argi])
elif arg=='-config' and have_next:
argi += 1
arg = sys.argv[argi]
try:
if arg.startswith('{'):
options['config'] = json.loads(arg)
else:
newconfig = read_config_file(arg, throw=True)
options['config'] = newconfig
options['_config_file'] = arg
except Exception as e:
if options['daemon']: log.exception(e)
raise e
elif arg=='-logfile' and have_next:
argi+=1
options['log_file'] = sys.argv[argi]
elif arg=='-stopateof':
argi+=1
options['stop_at_eof'] = True
elif arg=='-posfile' and have_next:
argi+=1
options['pos_file'] = sys.argv[argi]
elif arg=='-sqlitefile' and have_next:
argi+=1
options['sqlite_file'] = sys.argv[argi]
elif arg.startswith('-'):
usage()
else:
if marg==0:
options['log_file'] = arg
elif marg==1:
options['pos_file'] = arg
elif marg==2:
options['sqlite_file'] = arg
else:
usage()
marg += 1
argi += 1
def set_working_dir(working_dir):
try:
if not os.path.exists(working_dir):
os.mkdir(working_dir, mode=0o770)
os.chdir(working_dir)
except Exception as e:
log.exception(e)
raise e
def close_stdio():
sys.stdout.close()
sys.stderr.close()
sys.stdin.close()
# if config.json exists in the default location start with that
# instead of `config_default`. config can still be changed with the
# command line argument "-config"
newconfig = read_config_file(config_default_file, throw=False)
if newconfig:
options['config'] = newconfig
options['_config_file'] = config_default_file
# process command line
process_cmdline(options)
# init logging & set working directory
if options['daemon']:
logging.basicConfig(
level = options['log_level'],
handlers= [
logging.handlers.SysLogHandler(address='/dev/log')
],
format = 'miabldap/capture %(message)s'
)
log.warning('MIAB-LDAP capture/SEM daemon starting: wd=%s; log_file=%s; pos_file=%s; db=%s',
options['working_dir'],
options['log_file'],
options['pos_file'],
options['sqlite_file']
)
close_stdio()
set_working_dir(options['working_dir'])
else:
logging.basicConfig(level=options['log_level'])
log.info('starting: log_file=%s; pos_file=%s; db=%s',
options['log_file'],
options['pos_file'],
options['sqlite_file']
)
# save runtime config
write_config(options['_runtime_config_file'], options['config'])
# start modules
log.info('config: %s', options['config'])
try:
db_conn_factory = SqliteConnFactory(
options['sqlite_file']
)
event_store = SqliteEventStore(
db_conn_factory
)
position_store = ReadPositionStoreInFile(
options['pos_file']
)
mail_tail = TailFile(
options['log_file'],
position_store,
options['stop_at_eof']
)
postfix_log_handler = PostfixLogHandler(
event_store,
capture_enabled = options['config'].get('capture',True),
drop_disposition = options['config'].get('drop_disposition')
)
mail_tail.add_handler(postfix_log_handler)
dovecot_log_handler = DovecotLogHandler(
event_store,
capture_enabled = options['config'].get('capture',True),
drop_disposition = options['config'].get('drop_disposition')
)
mail_tail.add_handler(dovecot_log_handler)
pruner = Pruner(
db_conn_factory,
policy=options['config']['prune_policy']
)
pruner.add_prunable(event_store)
except Exception as e:
if options['daemon']: log.exception(e)
raise e
# termination handler for graceful shutdowns
def terminate(sig, stack):
if sig == signal.SIGTERM:
log.warning("shutting down due to SIGTERM")
log.debug("stopping mail_tail")
mail_tail.stop()
# reload settings handler
def reload(sig, stack):
# if the default config (`config_default`) is in use, check to see
# if a default config.json (`config_default_file`) now exists, and
# if so, use that
if options['config'].get('default_config', False) and os.path.exists(config_default_file):
options['config']['default_config'] = False
options['_config_file'] = config_default_file
log.info('%s mta records are in-progress',
postfix_log_handler.get_inprogress_count())
log.info('%s imap records are in-progress',
dovecot_log_handler.get_inprogress_count())
if options['_config_file']:
log.info('reloading %s', options['_config_file'])
try:
newconfig = read_config_file(options['_config_file'], throw=True)
pruner.set_policy(
newconfig['prune_policy']
)
postfix_log_handler.set_capture_enabled(
newconfig.get('capture', True)
)
postfix_log_handler.update_drop_disposition(
newconfig.get('drop_disposition', {})
)
dovecot_log_handler.set_capture_enabled(
newconfig.get('capture', True)
)
dovecot_log_handler.update_drop_disposition(
newconfig.get('drop_disposition', {})
)
write_config(options['_runtime_config_file'], newconfig)
except Exception as e:
if options['daemon']:
log.exception(e)
else:
raise e
signal.signal(signal.SIGTERM, terminate)
signal.signal(signal.SIGINT, terminate)
signal.signal(signal.SIGHUP, reload)
# monitor and capture
mail_tail.start()
mail_tail.join()
# gracefully close other threads
log.debug("stopping pruner")
pruner.stop()
log.debug("stopping position_store")
position_store.stop()
log.debug("stopping event_store")
event_store.stop()
try:
os.remove(options['_runtime_config_file'])
except Exception:
pass
log.info("stopped")