# -*- indent-tabs-mode: t; tab-width: 4; python-indent-offset: 4; -*- ##### ##### 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. ##### import os import logging import json import datetime import time import subprocess log = logging.getLogger(__name__) from flask import request, Response, session, redirect, jsonify, send_from_directory from functools import wraps from reporting.capture.db.SqliteConnFactory import SqliteConnFactory import reporting.uidata as uidata from mailconfig import ( get_mail_users, validate_email ) def add_reports(app, env, authorized_personnel_only): '''call this function to add reporting/sem endpoints `app` is a Flask instance `env` is the Mail-in-a-Box LDAP environment `authorized_personnel_only` is a flask wrapper from daemon.py ensuring only authenticated admins can access the endpoint ''' CAPTURE_STORAGE_ROOT = os.environ.get( 'CAPTURE_STORAGE_ROOT', os.path.join(env['STORAGE_ROOT'], 'reporting') ) sqlite_file = os.path.join(CAPTURE_STORAGE_ROOT, 'capture.sqlite') db_conn_factory = SqliteConnFactory(sqlite_file) # UI support ui_dir = os.path.join(os.path.dirname(app.template_folder), 'reporting/ui') def send_ui_file(filename): return send_from_directory(ui_dir, filename) @app.route("/reports/ui/", methods=['GET']) def get_reporting_ui_file(filename): return send_ui_file(filename) @app.route('/reports') def reporting_redir(): return redirect('/reports/') @app.route('/reports/', methods=['GET']) def reporting_main(): return send_ui_file('index.html') # Decorator to unwrap json payloads. It returns the json as a dict # in named argument 'payload' def json_payload(func): @wraps(func) def wrapper(*args, **kwargs): try: log.debug('payload:%s', request.data) payload = json.loads(request.data) return func(*args, payload=payload, **kwargs) except json.decoder.JSONDecodeError as e: log.warning('Bad request: data:%s ex:%s', request.data, e) return ("Bad request", 400) return wrapper @app.route('/reports/uidata/messages-sent', methods=['POST']) @authorized_personnel_only @json_payload def get_data_chart_messages_sent(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.messages_sent(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/messages-received', methods=['POST']) @authorized_personnel_only @json_payload def get_data_chart_messages_received(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.messages_received(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/user-activity', methods=['POST']) @authorized_personnel_only @json_payload def get_data_user_activity(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.user_activity(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/imap-details', methods=['POST']) @authorized_personnel_only @json_payload def get_imap_details(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.imap_details(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/flagged-connections', methods=['POST']) @authorized_personnel_only @json_payload def get_data_flagged_connections(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.flagged_connections(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/remote-sender-activity', methods=['POST']) @authorized_personnel_only @json_payload def get_data_remote_sender_activity(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.remote_sender_activity(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/user-list', methods=['GET']) @authorized_personnel_only def get_data_user_list(): return jsonify(get_mail_users(env, as_map=False)) @app.route('/reports/uidata/select-list-suggestions', methods=['POST']) @authorized_personnel_only @json_payload def suggest(payload): conn = db_conn_factory.connect() try: return jsonify(uidata.select_list_suggestions(conn, payload)) except uidata.InvalidArgsError as e: return ('invalid request', 400) finally: db_conn_factory.close(conn) @app.route('/reports/capture/config', methods=['GET']) @authorized_personnel_only def get_capture_config(): try: with open("/var/run/mailinabox/runtime_config.json") as fp: return Response(fp.read(), mimetype="text/json") except FileNotFoundError: return jsonify({ 'status':'error', 'reason':'not running' }) @app.route('/reports/capture/config', methods=['POST']) @authorized_personnel_only @json_payload def save_capture_config(payload): try: with open("/var/run/mailinabox/runtime_config.json") as fp: loc = json.loads(fp.read()).get('from', { 'type':'unknown' }) except FileNotFoundError: return ('service is not running', 403) # loc: { type:'file', location:'' } if loc.get('type') != 'file': return ('storage type is %s' % loc.get('type'), 403) log.warning('overwriting config file %s', loc['location']) # remove runtime-config extra fields that don't belong in # the user config if 'from' in payload: del payload['from'] with open(loc['location'], "w") as fp: fp.write(json.dumps(payload, indent=4)) r = subprocess.run(["systemctl", "reload", "miabldap-capture"]) if r.returncode != 0: log.warning('systemctl reload failed for miabldap-capture: code=%s', r.returncode) else: # wait a sec for daemon to pick up new config # TODO: monitor runtime config for mtime change time.sleep(1) # invalidate stats cache. if prune policy changed, the stats # may be invalid uidata.clear_cache() return ("ok", 200) @app.route('/reports/capture/service/status', methods=['GET']) @authorized_personnel_only def get_capture_status(): service = "miabldap-capture.service" if not os.path.exists("/etc/systemd/system/" + service): return jsonify([ 'not installed', 'not installed' ]) r1 = subprocess.run(["systemctl", "is-active", "--quiet", service ]) r2 = subprocess.run(["systemctl", "is-enabled", "--quiet", service ]) return jsonify([ 'running' if r1.returncode == 0 else 'stopped', 'enabled' if r2.returncode == 0 else 'disabled' ]) @app.route('/reports/capture/db/stats', methods=['GET']) @authorized_personnel_only def get_db_stats(): conn = db_conn_factory.connect() try: return jsonify(uidata.capture_db_stats(conn)) finally: db_conn_factory.close(conn) @app.route('/reports/uidata/message-headers', methods=['POST']) @authorized_personnel_only @json_payload def get_message_headers(payload): try: user_id = payload['user_id'] lmtp_id = payload['lmtp_id'] except KeyError: return ('invalid request', 400) if not validate_email(user_id, mode="user"): return ('invalid email address', 400) r = subprocess.run( [ "/usr/bin/doveadm", "fetch", "-u",user_id, "hdr", "HEADER","received","LMTP id " + lmtp_id ], encoding="utf8", stdout=subprocess.PIPE, stderr=subprocess.PIPE ) if r.returncode != 0: log.error('retrieving message headers failed, code=%s, lmtp_id=%s, user_id=%s, stderr=%s', r.returncode, lmtp_id, user_id, r.stderr) return Response(r.stderr, status=400, mimetype='text/plain') else: out = r.stdout.strip() if out.startswith('hdr:\n'): out = out[5:] return Response(out, status=200, mimetype='text/plain')