From 5890142ce60e5e1e86540de3eda4cbf6fcce50e1 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 18 May 2026 15:28:09 +0000 Subject: [PATCH] feat: add smtp_config.py - read SMTP settings from NS8 Redis cluster state --- ns8_backup_monitor/smtp_config.py | 189 ++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 ns8_backup_monitor/smtp_config.py diff --git a/ns8_backup_monitor/smtp_config.py b/ns8_backup_monitor/smtp_config.py new file mode 100644 index 0000000..45a1128 --- /dev/null +++ b/ns8_backup_monitor/smtp_config.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +smtp_config.py - Reads the SMTP relay configuration from NS8 cluster Redis. + +NS8 stores the mail relay settings (configured via the 'Mail relay' panel or +the `mail` module) in Redis under: + + cluster/mail_settings (hash) - fields: relay_host, relay_port, relay_tls, + relay_username, relay_password, + mail_domain, mail_from + +This module reads those values and returns a dict compatible with the +`smtp` and `mail` sections expected by notifier.py, so that ns8-backup-monitor +always uses the same relay that NS8 itself uses for system notifications. + +Fallback chain: + 1. NS8 Redis SMTP config (cluster/mail_settings) + 2. config.yml [smtp] section + 3. localhost:25 unauthenticated +""" + +import logging +import subprocess +from typing import Optional + +log = logging.getLogger(__name__) + +# NS8 Redis key for global mail relay settings +NS8_MAIL_SETTINGS_KEY = "cluster/mail_settings" + +# Alternative key used by some NS8 versions / mail module +NS8_MAIL_SETTINGS_ALT = "module/mail1/settings" + + +def _redis_hgetall(socket: str, key: str) -> dict: + """Read a Redis hash as a dict via redis-cli.""" + cmd = ["redis-cli", "-s", socket, "HGETALL", key] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + lines = [l for l in result.stdout.strip().splitlines() if l] + return dict(zip(lines[::2], lines[1::2])) + except Exception as e: + log.warning(f"redis-cli HGETALL {key} failed: {e}") + return {} + + +def _try_keys(socket: str) -> dict: + """Try the known NS8 Redis keys for mail settings, return first non-empty.""" + for key in (NS8_MAIL_SETTINGS_KEY, NS8_MAIL_SETTINGS_ALT): + fields = _redis_hgetall(socket, key) + if fields: + log.info(f"Found NS8 SMTP config at Redis key: {key}") + return fields + return {} + + +def load_ns8_smtp(config: dict) -> Optional[dict]: + """ + Read NS8 SMTP relay config from Redis. + + Returns a dict with keys: + smtp.host, smtp.port, smtp.use_tls, smtp.use_starttls, + smtp.username, smtp.password, + mail.from (system sender address) + + Returns None if not found or Redis is unreachable. + """ + socket = config.get("redis", {}).get( + "socket", "/var/lib/nethserver/cluster/state/redis.sock" + ) + fields = _try_keys(socket) + + if not fields: + log.debug("No NS8 mail settings found in Redis") + return None + + # NS8 field names (may vary by version — we handle both snake_case and camelCase) + host = ( + fields.get("relay_host") + or fields.get("relayHost") + or fields.get("smarthost") + or "" + ).strip() + + if not host: + log.debug("NS8 mail settings found in Redis but relay_host is empty (direct delivery)") + return None + + try: + port = int( + fields.get("relay_port") + or fields.get("relayPort") + or fields.get("smarthost_port") + or 587 + ) + except (ValueError, TypeError): + port = 587 + + # TLS flags - NS8 stores as string 'true'/'false' or '1'/'0' + def _bool(val) -> bool: + return str(val).lower() in ("true", "1", "yes") + + use_tls = _bool( + fields.get("relay_tls") or fields.get("tls") or fields.get("relayTls") or False + ) + # STARTTLS is the default for port 587; detect from port if not explicit + use_starttls = _bool( + fields.get("relay_starttls") + or fields.get("starttls") + or fields.get("relayStarttls") + or (port == 587 and not use_tls) + ) + + username = ( + fields.get("relay_username") + or fields.get("relayUsername") + or fields.get("username") + or "" + ).strip() + password = ( + fields.get("relay_password") + or fields.get("relayPassword") + or fields.get("password") + or "" + ).strip() + + mail_from = ( + fields.get("mail_from") + or fields.get("mailFrom") + or fields.get("sender") + or f"ns8-backup-monitor@{fields.get('mail_domain', 'localhost')}" + ).strip() + + result = { + "smtp": { + "host": host, + "port": port, + "use_tls": use_tls, + "use_starttls": use_starttls, + "username": username, + "password": password, + }, + "mail_from": mail_from, + } + + log.info( + f"NS8 SMTP relay: {host}:{port} " + f"tls={use_tls} starttls={use_starttls} " + f"auth={'yes' if username else 'no'} " + f"from={mail_from}" + ) + return result + + +def resolve_smtp_config(config: dict) -> tuple: + """ + Resolve the effective SMTP configuration using the fallback chain: + 1. NS8 Redis cluster/mail_settings + 2. config.yml [smtp] + [mail] sections + 3. localhost:25 unauthenticated (last resort) + + Returns (smtp_cfg: dict, mail_from: str). + smtp_cfg keys: host, port, use_tls, use_starttls, username, password + """ + # Try NS8 Redis first, unless explicitly disabled in config + use_ns8_smtp = config.get("smtp", {}).get("use_ns8_relay", True) + + if use_ns8_smtp: + ns8 = load_ns8_smtp(config) + if ns8: + # Merge: NS8 provides smtp + mail_from, but mail.to still comes from config.yml + smtp_cfg = ns8["smtp"] + mail_from = config.get("mail", {}).get("from") or ns8["mail_from"] + return smtp_cfg, mail_from + else: + log.info("NS8 SMTP config not available, falling back to config.yml smtp section") + else: + log.info("NS8 relay lookup disabled (smtp.use_ns8_relay: false), using config.yml") + + # Fallback: config.yml + smtp_cfg = config.get("smtp", {}) + mail_from = config.get("mail", {}).get("from", "ns8-backup-monitor@localhost") + + if not smtp_cfg.get("host"): + log.warning("No SMTP host configured in config.yml, falling back to localhost:25") + smtp_cfg = {"host": "localhost", "port": 25, "use_tls": False, + "use_starttls": False, "username": "", "password": ""} + + return smtp_cfg, mail_from