mirror of
https://github.com/skoelle/dyndns-updater.git
synced 2026-09-17 16:10:25 +00:00
notify feature
This commit is contained in:
@@ -21,6 +21,14 @@ POLL_INTERVAL_MINUTES=15
|
|||||||
# Healthchecks (self-hosted)
|
# Healthchecks (self-hosted)
|
||||||
HEALTHCHECK_PING_URL=https://healthchecks.example.org/ping/change-me
|
HEALTHCHECK_PING_URL=https://healthchecks.example.org/ping/change-me
|
||||||
|
|
||||||
|
# E-Mail-Notification bei IP-Änderung (optional)
|
||||||
|
SMTP_HOST=smtp.example.org
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=change-me
|
||||||
|
SMTP_PASSWORD=change-me
|
||||||
|
NOTIFY_EMAIL_TO=you@example.org
|
||||||
|
NOTIFY_EMAIL_FROM=dyndns@example.org
|
||||||
|
|
||||||
# Sonstiges
|
# Sonstiges
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
## Phase 2 - Kernmodule
|
## Phase 2 - Kernmodule
|
||||||
- [x] config.py, state.py, fritzbox.py, cloudflare.py, freedns.py, healthcheck.py, main.py
|
- [x] config.py, state.py, fritzbox.py, cloudflare.py, freedns.py, healthcheck.py, main.py
|
||||||
|
- [x] notify.py (E-Mail-Notification bei IP-Aenderung)
|
||||||
|
|
||||||
## Phase 3 - Lokaler Test
|
## Phase 3 - Lokaler Test
|
||||||
- [ ] .env lokal befuellen (nicht committen)
|
- [ ] .env lokal befuellen (nicht committen)
|
||||||
|
|||||||
@@ -39,6 +39,20 @@ Optional: `FREEDNS_UPDATE_URL`, `POLL_INTERVAL_MINUTES`,
|
|||||||
`HEALTHCHECK_PING_URL`, `TZ`, `LOG_LEVEL`. Die IP kommt ausschließlich aus der
|
`HEALTHCHECK_PING_URL`, `TZ`, `LOG_LEVEL`. Die IP kommt ausschließlich aus der
|
||||||
FritzBox, nie aus Webhook-Parametern.
|
FritzBox, nie aus Webhook-Parametern.
|
||||||
|
|
||||||
|
### E-Mail-Notification (optional)
|
||||||
|
|
||||||
|
Bei IP-Änderung wird eine HTML-E-Mail mit Emojis versendet, wenn SMTP-Konfiguration
|
||||||
|
vorliegt:
|
||||||
|
|
||||||
|
| Variable | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `SMTP_HOST` | SMTP-Server (z.B. `smtp.gmail.com`) |
|
||||||
|
| `SMTP_PORT` | Port (Default: `587`) |
|
||||||
|
| `SMTP_USER` | SMTP-Login |
|
||||||
|
| `SMTP_PASSWORD` | SMTP-Passwort |
|
||||||
|
| `NOTIFY_EMAIL_TO` | Empfänger-Adresse |
|
||||||
|
| `NOTIFY_EMAIL_FROM` | Absender-Adresse |
|
||||||
|
|
||||||
## Betrieb
|
## Betrieb
|
||||||
|
|
||||||
- **State (bewusst ohne Volume, ephemeral):** zuletzt bekannte IP liegt als
|
- **State (bewusst ohne Volume, ephemeral):** zuletzt bekannte IP liegt als
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle.
|
|||||||
2. IP_letzte = state.load()
|
2. IP_letzte = state.load()
|
||||||
3. Wenn gleich: Poll -> nur Heartbeat-Ping, kein Update. Webhook/Start -> nur Log.
|
3. Wenn gleich: Poll -> nur Heartbeat-Ping, kein Update. Webhook/Start -> nur Log.
|
||||||
4. Wenn unterschiedlich: Cloudflare-Records updaten, FreeDNS aufrufen, state speichern,
|
4. Wenn unterschiedlich: Cloudflare-Records updaten, FreeDNS aufrufen, state speichern,
|
||||||
Healthcheck-Erfolgs-Ping. Bei Teilfehler: state NICHT speichern, Fail-Ping.
|
Healthcheck-Erfolgs-Ping, E-Mail-Notification (optional). Bei Teilfehler: state NICHT
|
||||||
|
speichern, Fail-Ping.
|
||||||
|
|
||||||
## 3. Cloudflare
|
## 3. Cloudflare
|
||||||
|
|
||||||
@@ -46,7 +47,14 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle.
|
|||||||
- FritzBox-TR-064-User mit eingeschraenkten Rechten
|
- FritzBox-TR-064-User mit eingeschraenkten Rechten
|
||||||
- Alle Records bleiben DNS-only (kein Proxy-Mode in v1)
|
- Alle Records bleiben DNS-only (kein Proxy-Mode in v1)
|
||||||
|
|
||||||
## 6. Offene Punkte
|
## 6. E-Mail-Notification (optional)
|
||||||
|
|
||||||
|
- HTML-E-Mail mit Emojis bei erfolgreicher IP-Aenderung
|
||||||
|
- SMTP-Auth mit TLS (Port 587)
|
||||||
|
- Nur wenn SMTP_HOST, NOTIFY_EMAIL_TO und NOTIFY_EMAIL_FROM gesetzt
|
||||||
|
- Plain-Text-Fallback fuer Clients ohne HTML-Unterstuetzung
|
||||||
|
|
||||||
|
## 7. Offene Punkte
|
||||||
|
|
||||||
- IPv6/AAAA
|
- IPv6/AAAA
|
||||||
- Proxy-Mode fuer reine HTTP(S)-Subdomains
|
- Proxy-Mode fuer reine HTTP(S)-Subdomains
|
||||||
|
|||||||
@@ -39,8 +39,19 @@ class Config:
|
|||||||
|
|
||||||
self.healthcheck_ping_url = os.environ.get("HEALTHCHECK_PING_URL", "")
|
self.healthcheck_ping_url = os.environ.get("HEALTHCHECK_PING_URL", "")
|
||||||
|
|
||||||
|
self.smtp_host = os.environ.get("SMTP_HOST", "")
|
||||||
|
self.smtp_port = int(os.environ.get("SMTP_PORT", "587"))
|
||||||
|
self.smtp_user = os.environ.get("SMTP_USER", "")
|
||||||
|
self.smtp_password = os.environ.get("SMTP_PASSWORD", "")
|
||||||
|
self.notify_email_to = os.environ.get("NOTIFY_EMAIL_TO", "")
|
||||||
|
self.notify_email_from = os.environ.get("NOTIFY_EMAIL_FROM", "")
|
||||||
|
|
||||||
self.state_path = os.environ.get("STATE_PATH", "/app/data/last-known-ip.json")
|
self.state_path = os.environ.get("STATE_PATH", "/app/data/last-known-ip.json")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email_notification_enabled(self) -> bool:
|
||||||
|
return bool(self.smtp_host and self.notify_email_to and self.notify_email_from)
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
missing = [v for v in REQUIRED_VARS if not os.environ.get(v)]
|
missing = [v for v in REQUIRED_VARS if not os.environ.get(v)]
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ import threading
|
|||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from app import cloudflare, freedns, healthcheck, state
|
from app import cloudflare, freedns, healthcheck, notify, state
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.fritzbox import FritzBoxError, get_external_ip
|
from app.fritzbox import FritzBoxError, get_external_ip
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ def run_cycle(trigger: str = "unknown"):
|
|||||||
status="success",
|
status="success",
|
||||||
message=f"IP updated to {current_ip}",
|
message=f"IP updated to {current_ip}",
|
||||||
)
|
)
|
||||||
|
notify.send_ip_changed_email(last_ip, current_ip)
|
||||||
else:
|
else:
|
||||||
failed = {k: v for k, v in cf_results.items() if v != "ok"}
|
failed = {k: v for k, v in cf_results.items() if v != "ok"}
|
||||||
log.error(
|
log.error(
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||||
|
# Licensed under the MIT License. See LICENSE file in project root for details.
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
log = logging.getLogger("notify")
|
||||||
|
|
||||||
|
|
||||||
|
def send_ip_changed_email(old_ip: str | None, new_ip: str) -> bool:
|
||||||
|
if not config.email_notification_enabled:
|
||||||
|
log.debug("E-Mail-Notification nicht konfiguriert, überspringe.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if old_ip is None:
|
||||||
|
old_ip = "unbekannt"
|
||||||
|
|
||||||
|
subject = f"🌐 IP-Update: {old_ip} → {new_ip}"
|
||||||
|
|
||||||
|
html = f"""\
|
||||||
|
<html>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f172a; color: #e2e8f0; padding: 32px; margin: 0;">
|
||||||
|
<div style="max-width: 480px; margin: 0 auto;">
|
||||||
|
<div style="text-align: center; font-size: 48px; margin-bottom: 16px;">🔄</div>
|
||||||
|
<h1 style="font-size: 20px; color: #38bdf8; text-align: center; margin-bottom: 24px;">
|
||||||
|
IP-Adresse geändert
|
||||||
|
</h1>
|
||||||
|
<div style="background: #1e293b; border-radius: 12px; padding: 24px; margin-bottom: 24px;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #94a3b8; font-size: 14px;">📍 Alte IP</td>
|
||||||
|
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 16px; color: #f87171;">
|
||||||
|
{old_ip}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" style="padding: 4px 0; text-align: center; font-size: 20px;">⬇️</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 0; color: #94a3b8; font-size: 14px;">🆕 Neue IP</td>
|
||||||
|
<td style="padding: 8px 0; text-align: right; font-family: monospace; font-size: 16px; color: #4ade80;">
|
||||||
|
{new_ip}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 13px; color: #64748b; text-align: center; margin: 0;">
|
||||||
|
📡 DynDNS-Updater · Cloudflare + FreeDNS aktualisiert
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
text = f"IP-Adresse geändert: {old_ip} → {new_ip}"
|
||||||
|
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = config.notify_email_from
|
||||||
|
msg["To"] = config.notify_email_to
|
||||||
|
msg.set_content(text)
|
||||||
|
msg.add_alternative(html, subtype="html")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP(config.smtp_host, config.smtp_port) as server:
|
||||||
|
server.ehlo()
|
||||||
|
server.starttls()
|
||||||
|
server.ehlo()
|
||||||
|
server.login(config.smtp_user, config.smtp_password)
|
||||||
|
server.send_message(msg)
|
||||||
|
log.info("E-Mail gesendet: %s -> %s", subject, config.notify_email_to)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("E-Mail-Versand fehlgeschlagen: %s", exc)
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user