#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import time
import socket
import shutil
import difflib
import re
import filecmp
import fnmatch
import sys
import syslog
import signal
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from datetime import datetime

# ================= CONFIGURATIONS =================
WATCH_DIRS = ['/var/efw', '/var/openfw']
APACHE_LOG = '/var/log/httpd/httpd_access'

BACKUP_DIR = '/var/backups/snapshots'
SETTINGS_FILE = '/var/efw/backup/settings'
AUDIT_NOTIFY_SETTINGS = '/var/openfw/config-audit/settings'
IGNORE_FILES_PATH = '/var/openfw/config-audit/ignore-files-audit'
EMAILRELAY_SETTINGS = '/var/openfw/emailrelay/settings'
EMAILRELAY_AUTH_FILE = '/etc/emailrelay.auth'

DEFAULT_MAX_BACKUPS = 5

SNAPSHOT_RE = re.compile(r'^(.+)\.(\d{8}_\d{6})\.bak$')

HOSTNAME = socket.gethostname().lower()
PID = os.getpid()
# ==================================================

def emit_status_log(message, priority=syslog.LOG_INFO):
    """Status logging routed to audit-config.log via unified syslog tag"""
    syslog.syslog(priority, "config-audit: status=\"" + message + "\"")

def emit_audit_log(log_payload):
    """Auditor logging routed to audit-config.log via unified syslog tag"""
    syslog.syslog(syslog.LOG_INFO, "config-audit: " + log_payload)

def handle_signal(sig, frame):
    emit_status_log("Service stopped gracefully.")
    sys.exit(0)

def load_ignore_files():
    """Reads ignored patterns from /var/openfw/config-audit/ignore-files-audit"""
    patterns = []
    if not os.path.exists(IGNORE_FILES_PATH):
        emit_status_log("Ignore files configuration not found or path incorrect: %s" % IGNORE_FILES_PATH, priority=syslog.LOG_ERR)
        return patterns

    try:
        with open(IGNORE_FILES_PATH, 'r') as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                patterns.append(line)
    except Exception as e:
        emit_status_log("Error reading ignore-files-audit config file: %s" % str(e), priority=syslog.LOG_ERR)

    return patterns

def load_audit_notify_config():
    """Reads email configuration from /var/openfw/config-audit/settings"""
    config = {}
    if not os.path.exists(AUDIT_NOTIFY_SETTINGS):
        return config

    try:
        with open(AUDIT_NOTIFY_SETTINGS, 'r') as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                if '=' in line:
                    key, val = line.split('=', 1)
                    config[key.strip()] = val.strip().strip('"\'')
    except Exception as e:
        emit_status_log("Error reading email settings: %s" % str(e), priority=syslog.LOG_ERR)
    return config

def load_emailrelay_config():
    """Reads emailrelay configuration from /var/openfw/emailrelay/settings"""
    config = {}
    if not os.path.exists(EMAILRELAY_SETTINGS):
        return config

    try:
        with open(EMAILRELAY_SETTINGS, 'r') as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                if '=' in line:
                    key, val = line.split('=', 1)
                    config[key.strip()] = val.strip().strip('"\'')
    except Exception as e:
        emit_status_log("Error reading emailrelay settings: %s" % str(e), priority=syslog.LOG_ERR)
    return config

def load_emailrelay_auth():
    """Reads client authentication credentials from /etc/emailrelay.auth"""
    if not os.path.exists(EMAILRELAY_AUTH_FILE):
        return None, None

    try:
        with open(EMAILRELAY_AUTH_FILE, 'r') as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith('#'):
                    continue
                # Matches: client plain username password OR client login username password
                parts = line.split()
                if len(parts) >= 4 and parts[0].lower() == 'client':
                    user = parts[2].strip('\'"')
                    pass_val = ' '.join(parts[3:]).strip('\'"')
                    return user, pass_val
    except Exception as e:
        emit_status_log("Error reading emailrelay.auth: %s" % str(e), priority=syslog.LOG_ERR)
    return None, None

def send_audit_email(user, ip, full_path, action, backup_file, diff_output):
    """Builds HTML template and dispatches SMTP email notification"""
    cfg = load_audit_notify_config()

    if cfg.get('ENABLED') != 'on':
        return

    use_relay = (cfg.get('USE_EMAILRELAY') == 'on')
    relay_cfg = load_emailrelay_config() if use_relay else {}

    # If USE_EMAILRELAY is checked, load Smarthost SMTP variables directly from EmailRelay settings
    relay_smarthost = relay_cfg.get('SMARTHOST_SERVER') or relay_cfg.get('SMARTHOST') or relay_cfg.get('SMTP_SERVER') or ''
    if use_relay and relay_smarthost:
        smtp_server = relay_smarthost
        smtp_port = relay_cfg.get('SMARTHOST_PORT') or relay_cfg.get('SMTP_PORT', '587')
        
        # Mapeamento do TLS do emailrelay (SMARTHOST_TLS=on -> tls)
        r_tls = relay_cfg.get('SMARTHOST_TLS', '')
        if r_tls == 'on':
            encryption = 'tls'
        else:
            encryption = relay_cfg.get('ENCRYPTION', 'none')

        # Prioritize credentials from /etc/emailrelay.auth
        auth_user, auth_pass = load_emailrelay_auth()
        if auth_user:
            smtp_user = auth_user
            smtp_pass = auth_pass
        else:
            smtp_user = relay_cfg.get('SMARTHOST_ACCOUNT') or relay_cfg.get('SMTP_USER') or relay_cfg.get('AUTH_USER', '')
            smtp_pass = relay_cfg.get('SMARTHOST_PASSWORD') or relay_cfg.get('SMTP_PASS') or relay_cfg.get('AUTH_PASS', '')
            
        smtp_auth = 'on' if smtp_user else 'off'
    else:
        smtp_server = cfg.get('SMTP_SERVER')
        smtp_port = cfg.get('SMTP_PORT', '587')
        encryption = cfg.get('ENCRYPTION', 'tls')
        smtp_auth = cfg.get('SMTP_AUTH', 'off')
        smtp_user = cfg.get('SMTP_USER', '')
        smtp_pass = cfg.get('SMTP_PASS', '')

    from_addr = cfg.get('FROM_ADDRESS', '')
    to_addr = cfg.get('TO_ADDRESS', '')
    subject_pfx = cfg.get('SUBJECT_PREFIX', '[OpenFW Audit]')

    if not smtp_server or not from_addr or not to_addr:
        emit_status_log("Email notification enabled but missing required parameters.", priority=syslog.LOG_WARNING)
        return

    recipients = [addr.strip() for addr in to_addr.split(',') if addr.strip()]
    if not recipients:
        return

    mtime_now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
    if action.startswith("created"):
        event_label = "creation"
        subject = "%s File Created: %s" % (subject_pfx, full_path)
    else:
        event_label = "modification"
        subject = "%s File Changed: %s" % (subject_pfx, full_path)

    # Format diff string into HTML elements
    diff_html_items = []
    if diff_output and diff_output != "no_text_change":
        for item in diff_output.split(" | "):
            if item.startswith("[before]"):
                diff_html_items.append('<div style="color: #d9534f; background: #fdf2f2; padding: 2px 6px; margin: 2px 0; border-left: 3px solid #d9534f; font-family: monospace;">%s</div>' % item)
            elif item.startswith("[after]"):
                diff_html_items.append('<div style="color: #008100; background: #f2f9f2; padding: 2px 6px; margin: 2px 0; border-left: 3px solid #008100; font-family: monospace;">%s</div>' % item)
            else:
                diff_html_items.append('<div style="color: #000000; font-family: monospace;">%s</div>' % item)
    else:
        diff_html_items.append('<span style="color: #777; font-style: italic;">No readable text diff available</span>')

    diff_html = "".join(diff_html_items)

    html_content = """
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body style="font-family: Arial, sans-serif; background-color: #f4f6f9; color: #000000; margin: 0; padding: 20px;">
    <div style="max-width: 680px; margin: 0 auto; background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: 0 2px 5px rgba(0,0,0,0.1); border: 1px solid #e1e4e8;">
        <div style="background-color: #008100; color: #ffffff; padding: 15px 20px;">
            <h2 style="margin: 0; font-size: 18px; font-weight: 600;">OpenFW UTM Community Audit Notification</h2>
        </div>
        <div style="padding: 20px;">
            <p style="margin-top: 0; font-size: 14px; color: #000000;">A file %s event was detected on hostname <strong>%s</strong>:</p>
            
            <table style="width: 100%%; border-collapse: collapse; margin-top: 15px; font-size: 13px;">
                <tr style="background-color: #f8f9fa;">
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-weight: bold; width: 30%%; color: #000000;">Date / Time</td>
                    <td style="padding: 10px; border: 1px solid #dee2e6; color: #000000;">%s</td>
                </tr>
                <tr>
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-weight: bold; color: #000000;">File Path</td>
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-family: monospace; font-weight: bold; color: #008100;">%s</td>
                </tr>
                <tr style="background-color: #f8f9fa;">
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-weight: bold; color: #000000;">User</td>
                    <td style="padding: 10px; border: 1px solid #dee2e6; color: #000000;">%s</td>
                </tr>
                <tr>
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-weight: bold; color: #000000;">IP Address</td>
                    <td style="padding: 10px; border: 1px solid #dee2e6; color: #000000;">%s</td>
                </tr>
                <tr style="background-color: #f8f9fa;">
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-weight: bold; color: #000000;">Action</td>
                    <td style="padding: 10px; border: 1px solid #dee2e6;"><span style="background: #008100; color: #ffffff; padding: 3px 8px; border-radius: 3px; font-weight: bold; font-size: 11px; text-transform: uppercase;">%s</span></td>
                </tr>
                <tr>
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-weight: bold; color: #000000;">Backup File</td>
                    <td style="padding: 10px; border: 1px solid #dee2e6; font-family: monospace; color: #000000;">%s</td>
                </tr>
            </table>

            <div style="margin-top: 20px;">
                <div style="font-weight: bold; font-size: 13px; margin-bottom: 8px; color: #000000;">Changes Summary (Diff):</div>
                <div style="background: #fafafa; border: 1px solid #e1e4e8; border-radius: 4px; padding: 10px; font-size: 12px; max-height: 250px; overflow-y: auto;">
                    %s
                </div>
            </div>
        </div>
        <div style="background: #f8f9fa; padding: 12px 20px; text-align: center; font-size: 11px; color: #000000; border-top: 1px solid #dee2e6;">
            OpenFW UTM Community • Automated Audit Notification Service
        </div>
    </div>
</body>
</html>
""" % (event_label, HOSTNAME, mtime_now, full_path, user, ip, action, backup_file, diff_html)

    msg = MIMEMultipart('alternative')
    msg['Subject'] = subject
    msg['From'] = from_addr
    msg['To'] = ", ".join(recipients)
    msg.attach(MIMEText(html_content, 'html', 'utf-8'))

    try:
        port = int(smtp_port)
        if encryption == 'ssl':
            server = smtplib.SMTP_SSL(smtp_server, port, timeout=10)
        else:
            server = smtplib.SMTP(smtp_server, port, timeout=10)
            if encryption == 'tls':
                server.starttls()

        if smtp_auth == 'on' and smtp_user:
            server.login(smtp_user, smtp_pass)

        server.sendmail(from_addr, recipients, msg.as_string())
        server.quit()

        # Logs simple email dispatch confirmation
        emit_status_log("Audit email notification sent successfully.")

    except Exception as e:
        emit_status_log("Failed to send audit notification email: %s" % str(e), priority=syslog.LOG_ERR)

def get_max_backups():
    try:
        with open(SETTINGS_FILE, 'r') as f:
            for line in f:
                line = line.strip()
                if line.startswith('BACKUP_RETENTION='):
                    value = line.split('=', 1)[1].strip().strip('"\'')
                    retention = int(value)
                    if retention >= 1:
                        return retention
    except Exception:
        pass
    return DEFAULT_MAX_BACKUPS

def is_ignored(name, ignore_patterns):
    for pattern in ignore_patterns:
        try:
            if fnmatch.fnmatchcase(name, pattern):
                return True
        except Exception as e:
            emit_status_log("Error matching pattern '%s': %s" % (pattern, str(e)), priority=syslog.LOG_ERR)
    return False

def get_last_post_user():
    if not os.path.exists(APACHE_LOG):
        return "log_not_found", "ip_unknown"
    
    try:
        with open(APACHE_LOG, 'r') as f:
            lines = f.readlines()[-100:]
            for line in reversed(lines):
                if '"POST ' in line or '"PUT ' in line or '"DELETE ' in line:
                    parts = line.split()
                    if len(parts) >= 3:
                        ip = parts[0]
                        user = parts[2]
                        if user != '-' and user != '':
                            return user.lower(), ip.lower()
    except Exception:
        pass
        
    return "unknown", "ip_unknown"

def read_file_lines(path):
    try:
        with open(path, 'r') as f:
            return f.readlines()
    except Exception:
        return []

def get_diff_text_syslog(old_lines, new_lines):
    diff = difflib.unified_diff(
        old_lines, 
        new_lines, 
        fromfile='before', 
        tofile='after', 
        lineterm=''
    )
    changes = []
    for line in diff:
        line_str = line.rstrip('\r\n')
        if line_str.startswith('-') and not line_str.startswith('---'):
            changes.append("[before] " + line_str[1:].strip().lower())
        elif line_str.startswith('+') and not line_str.startswith('+++'):
            changes.append("[after] " + line_str[1:].strip().lower())
            
    if not changes:
        return "no_text_change"
    return " | ".join(changes)

def make_backup_and_rotate(changed_file):
    try:
        if not os.path.exists(BACKUP_DIR):
            os.makedirs(BACKUP_DIR)

        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        safe_filename = changed_file.strip('/').replace('/', '_')
        backup_filename = "%s.%s.bak" % (safe_filename, timestamp)
        backup_path = os.path.join(BACKUP_DIR, backup_filename)

        shutil.copy2(changed_file, backup_path)

        prefix = safe_filename + "."
        existing_backups = []
        
        for f in os.listdir(BACKUP_DIR):
            if f.startswith(prefix) and f.endswith('.bak'):
                full_b_path = os.path.join(BACKUP_DIR, f)
                existing_backups.append(full_b_path)

        existing_backups.sort(key=os.path.getmtime)

        max_backups = get_max_backups()
        if len(existing_backups) > max_backups:
            to_remove = len(existing_backups) - max_backups
            for old_bak in existing_backups[:to_remove]:
                os.remove(old_bak)

        return backup_filename
    except Exception:
        return "none"

def load_latest_snapshots():
    latest = {}
    try:
        for f in os.listdir(BACKUP_DIR):
            m = SNAPSHOT_RE.match(f)
            if m:
                base = m.group(1)
                stamp = m.group(2)
                if base not in latest or stamp > latest[base][0]:
                    latest[base] = (stamp, f)
    except OSError:
        pass
    return latest

def sync_snapshot(full_path, latest):
    try:
        if not os.path.isfile(full_path):
            return

        safe_filename = full_path.strip('/').replace('/', '_')
        entry = latest.get(safe_filename)

        if entry is None:
            if latest:
                action = "created_offline"
                user = "unknown"
                ip = "ip_unknown"
                diff_output = get_diff_text_syslog([], read_file_lines(full_path))
            else:
                action = "baseline"
                user = "system"
                ip = "local"
                diff_output = "baseline_snapshot"
        else:
            snapshot_path = os.path.join(BACKUP_DIR, entry[1])
            if filecmp.cmp(snapshot_path, full_path, shallow=False):
                return
            action = "modified_offline"
            user = "unknown"
            ip = "ip_unknown"
            diff_output = get_diff_text_syslog(read_file_lines(snapshot_path), read_file_lines(full_path))

        file_mtime = datetime.fromtimestamp(os.path.getmtime(full_path)).strftime('%Y-%m-%d %H:%M:%S')
        backup_file = make_backup_and_rotate(full_path)

        log_payload = 'user="%s" ip="%s" file="%s" action="%s" backup="%s" mtime="%s" diff="%s"' % (
            user, ip, full_path.lower(), action, backup_file, file_mtime, diff_output
        )
        emit_audit_log(log_payload)

        # Trigger email notification for offline changes detected on startup
        if action in ("modified_offline", "created_offline"):
            send_audit_email(user, ip, full_path, action, backup_file, diff_output)

    except Exception:
        pass

def main():
    signal.signal(signal.SIGINT, handle_signal)
    signal.signal(signal.SIGTERM, handle_signal)

    # Open syslog connection once at startup
    syslog.openlog("config-audit", syslog.LOG_PID, syslog.LOG_DAEMON)

    try:
        import subprocess
        p = subprocess.Popen(['rpm', '-qa'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        out, _ = p.communicate()
        if 'inotify' not in out:
            emit_status_log("The 'inotify' package is not installed.", priority=syslog.LOG_ERR)
            sys.exit(1)
    except Exception:
        emit_status_log("The 'inotify' package is not installed.", priority=syslog.LOG_ERR)
        sys.exit(1)

    emit_status_log("Service started. Monitoring %s" % WATCH_DIRS)

    ignore_patterns = load_ignore_files()

    file_states = {}
    file_contents = {}
    latest_snapshots = load_latest_snapshots()

    for watch_dir in WATCH_DIRS:
        if os.path.exists(watch_dir):
            for root, dirs, files in os.walk(watch_dir):
                for f in files:
                    if is_ignored(f, ignore_patterns):
                        continue
                    full_path = os.path.join(root, f)
                    try:
                        file_states[full_path] = os.path.getmtime(full_path)
                        file_contents[full_path] = read_file_lines(full_path)
                        sync_snapshot(full_path, latest_snapshots)
                    except OSError:
                        pass

    while True:
        time.sleep(0.3)
        for watch_dir in WATCH_DIRS:
            if not os.path.exists(watch_dir):
                continue
            for root, dirs, files in os.walk(watch_dir):
                for f in files:
                    if is_ignored(f, ignore_patterns):
                        continue
                        
                    full_path = os.path.join(root, f)
                    try:
                        mtime = os.path.getmtime(full_path)
                        
                        if full_path not in file_states or mtime > file_states[full_path]:
                            new_lines = read_file_lines(full_path)
                            
                            if full_path in file_states:
                                action = "modified"
                                old_lines = file_contents.get(full_path, [])
                                diff_output = get_diff_text_syslog(old_lines, new_lines)
                                should_notify = (diff_output != "no_text_change")
                            else:
                                action = "created"
                                diff_output = get_diff_text_syslog([], new_lines)
                                should_notify = True

                            if should_notify:
                                user, ip = get_last_post_user()
                                backup_file = make_backup_and_rotate(full_path)

                                log_payload = 'user="%s" ip="%s" file="%s" action="%s" backup="%s" diff="%s"' % (
                                    user, ip, full_path.lower(), action, backup_file, diff_output
                                )
                                emit_audit_log(log_payload)

                                send_audit_email(user, ip, full_path, action, backup_file, diff_output)

                            file_states[full_path] = mtime
                            file_contents[full_path] = new_lines
                    except OSError:
                        pass

if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        emit_status_log("Service stopped manually.")
        sys.exit(0)