ops: snolla SMTP rollout 2026-08-14 — monitor deployed (VDS cron+ntfy), msmtp/backup-mail on new creds, pilonuxt compose tag 954e29e

This commit is contained in:
2026-08-14 12:31:57 +03:00
parent f1350f8533
commit ddfb6a304b
4 changed files with 182 additions and 5 deletions

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""snolla-smtp-monitor — health check of snolla sites' SMTP mail sending (lives on vds-kzntsv).
Background: 2026-08-12..14 Yandex disabled SMTP for noreply@snolla.com → site forms kept
answering "success" while order emails silently died for 2 days (mailer swallows errors).
This monitor catches that class of failure at the next tick.
Checks (run daily via /etc/cron.d/snolla-smtp-monitor):
1. SMTP auth against smtp.yandex.ru:465 with the current snolla mailer creds
(catches 525 SMTP disabled / 535 invalid login / connection failure).
2. Config parity: SMTP_USER/SMTP_PASSWORD in Portainer stack envs of the env-driven
snolla sites must equal the expected creds (catches config drift before it breaks).
3. Weekly (Mon): real test send to OPS_NOTIFY_EMAIL (end-to-end proof through Yandex).
On failure: ntfy push (topic vds-ops) + exit 1. On success: log line only (silent).
Config: /root/.snolla-smtp-monitor.env (chmod 600; mirror of `pass snolla-smtp/full-env` +
ntfy + portainer sections of `pass vds-kzntsv/full-env`). Keys:
SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS OPS_NOTIFY_EMAIL
NTFY_URL NTFY_USER NTFY_PASS NTFY_TOPIC
PORTAINER_URL PORTAINER_USER PORTAINER_PASS PORTAINER_STACKS(comma list of ids or names)
"""
import base64
import datetime
import email.message
import json
import smtplib
import ssl
import sys
import urllib.request
ENV_PATH = "/root/.snolla-smtp-monitor.env"
LOG_PATH = "/var/log/snolla-smtp-monitor.log"
# Env-driven snolla stacks (SMTP creds in Portainer stack env). pilonuxt (16) excluded —
# its config is baked into the image; covered by check 1.
DEFAULT_STACKS = [17, 18, 19, 20, 21, 22, 23]
def load_env(path):
cfg = {}
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
k, _, v = line.partition("=")
cfg[k.strip()] = v.strip()
return cfg
def log(cfg, msg):
ts = datetime.datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
line = f"{ts} {msg}"
print(line, flush=True)
try:
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception as e:
print(f"log write failed: {e}", flush=True)
def ntfy_alert(cfg, title, body):
try:
url = f"{cfg['NTFY_URL']}/{cfg.get('NTFY_TOPIC', 'vds-ops')}"
req = urllib.request.Request(url, data=body.encode(), method="POST")
token = base64.b64encode(f"{cfg['NTFY_USER']}:{cfg['NTFY_PASS']}".encode()).decode()
req.add_header("Authorization", "Basic " + token)
# urllib encodes header VALUES as latin-1 → keep title ASCII (emoji goes in body)
req.add_header("Title", title.encode("ascii", "replace").decode("ascii"))
req.add_header("Priority", "high")
with urllib.request.urlopen(req, timeout=15) as resp:
log(cfg, f"ntfy alert sent: HTTP {resp.status}")
except Exception as e:
log(cfg, f"ntfy push FAILED: {e}")
def smtp_login(cfg):
ctx = ssl.create_default_context()
s = smtplib.SMTP_SSL(cfg["SMTP_HOST"], int(cfg.get("SMTP_PORT", 465)),
context=ctx, timeout=25)
try:
s.login(cfg["SMTP_USER"], cfg["SMTP_PASS"])
finally:
s.quit()
return True
def smtp_send_test(cfg):
msg = email.message.EmailMessage()
msg["From"] = cfg["SMTP_USER"]
msg["To"] = cfg["OPS_NOTIFY_EMAIL"]
msg["Subject"] = "[snolla-monitor] weekly SMTP send test OK"
msg.set_content(
"Automated weekly test send from snolla-smtp-monitor (vds-kzntsv). "
"If you see this, SMTP sending for snolla sites works.\n"
f"ts={datetime.datetime.now().astimezone().isoformat()}"
)
ctx = ssl.create_default_context()
s = smtplib.SMTP_SSL(cfg["SMTP_HOST"], int(cfg.get("SMTP_PORT", 465)),
context=ctx, timeout=25)
try:
s.login(cfg["SMTP_USER"], cfg["SMTP_PASS"])
s.send_message(msg)
finally:
s.quit()
return True
def portainer_parity(cfg, expected_user, expected_pass):
"""Every env-driven snolla stack must carry the expected SMTP creds in its env."""
bad = []
# JWT auth (API key gave 401 historically — use admin password JWT)
auth_req = urllib.request.Request(
f"{cfg['PORTAINER_URL']}/api/auth",
data=json.dumps({"username": cfg["PORTAINER_USER"],
"password": cfg["PORTAINER_PASS"]}).encode(),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(auth_req, timeout=15) as r:
jwt = json.loads(r.read())["jwt"]
stacks_req = urllib.request.Request(f"{cfg['PORTAINER_URL']}/api/stacks",
headers={"Authorization": "Bearer " + jwt})
with urllib.request.urlopen(stacks_req, timeout=15) as r:
stacks = json.loads(r.read())
by_id = {s["Id"]: s for s in stacks}
for sid in cfg.get("PORTAINER_STACKS", DEFAULT_STACKS):
s = by_id.get(int(sid))
if s is None:
bad.append(f"stack {sid}: NOT FOUND")
continue
env = {e.get("name"): e.get("value") for e in s.get("Env", [])}
u, p = env.get("SMTP_USER"), env.get("SMTP_PASSWORD")
if u != expected_user or p != expected_pass:
bad.append(f"stack {sid} ({s.get('Name')}): SMTP env MISMATCH "
f"(user={u!r} pass_len={len(p) if p else 0})")
return bad
def main():
cfg = load_env(ENV_PATH)
problems = []
# 1. SMTP auth
try:
smtp_login(cfg)
log(cfg, "OK smtp-auth")
except Exception as e:
problems.append(f"SMTP auth FAILED: {e}")
# 3. weekly real send (Monday)
if datetime.date.today().weekday() == 0:
try:
smtp_send_test(cfg)
log(cfg, "OK weekly-send")
except Exception as e:
problems.append(f"weekly test send FAILED: {e}")
# 2. Portainer env parity
try:
bad = portainer_parity(cfg, cfg["SMTP_USER"], cfg["SMTP_PASS"])
if bad:
problems.append("parity: " + "; ".join(bad))
else:
log(cfg, "OK parity")
except Exception as e:
problems.append(f"parity check FAILED: {e}")
if problems:
body = "\n".join(problems)
log(cfg, "ALERT: " + body)
ntfy_alert(cfg, "snolla SMTP monitor: FAIL", body)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())