1
0
mirror of https://github.com/mail-in-a-box/mailinabox.git synced 2025-04-04 00:17:06 +00:00
mailinabox/management/reporting/capture/capture.py
downtownallday 2a0e50c8d4 Initial commit of a log capture and reporting feature
This adds a new section to the admin panel called "Activity", that
supplies charts, graphs and details about messages entering and leaving
the host.

A new daemon captures details of system mail activity by monitoring
the /var/log/mail.log file, summarizing it into a sqllite database
that's kept in user-data.
2021-01-11 18:02:07 -05:00

301 lines
8.5 KiB
Python
Executable File

#!/usr/bin/env python3
from logs.TailFile import TailFile
from mail.InboundMailLogHandler import InboundMailLogHandler
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",
'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=='-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
)
inbound_mail_handler = InboundMailLogHandler(
event_store,
capture_enabled = options['config'].get('capture',True),
drop_disposition = options['config'].get('drop_disposition')
)
mail_tail.add_handler(inbound_mail_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()
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")
# 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 records are in-progress',
inbound_mail_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']
)
inbound_mail_handler.set_capture_enabled(
newconfig.get('capture', True)
)
inbound_mail_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()