Compare commits

..
6 Commits
Author SHA1 Message Date
Stefan Koelle abd734b2dd Merge pull request #1 from skoelle/renovate/python-dependencies
Update Python dependencies
2026-08-10 22:55:19 +02:00
Stefan Koelle 21cfd1aaaa Merge pull request #2 from skoelle/renovate/major-github-actions
Update GitHub Actions (major)
2026-08-10 22:55:05 +02:00
renovate[bot] 7c3bcd9787 Update GitHub Actions 2026-08-10 20:54:11 +00:00
renovate[bot] 83817884f8 Update Python dependencies 2026-08-10 20:54:05 +00:00
stefankoelle 539e0e5853 renovate.json 2026-08-10 22:49:50 +02:00
stefankoelle 70efc66696 notify feature 2026-08-10 22:43:06 +02:00
11 changed files with 170 additions and 14 deletions
+8
View File
@@ -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
+5 -5
View File
@@ -14,25 +14,25 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Log in to GHCR - name: Log in to GHCR
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata - name: Extract metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ghcr.io/${{ github.repository_owner }}/dyndns-updater images: ghcr.io/${{ github.repository_owner }}/dyndns-updater
tags: | tags: |
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
push: true push: true
+2 -2
View File
@@ -9,8 +9,8 @@ jobs:
ruff: ruff:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/setup-python@v5 - uses: actions/setup-python@v7
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install ruff - run: pip install ruff
+1
View File
@@ -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)
+14
View File
@@ -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
+10 -2
View File
@@ -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
+11
View File
@@ -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
View File
@@ -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(
+77
View File
@@ -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
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"schedule": ["before 6am on Monday"],
"packageRules": [
{
"matchManagers": ["github-actions"],
"groupName": "GitHub Actions",
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"groupName": "Docker",
"automerge": false
},
{
"matchManagers": ["docker-compose"],
"groupName": "Docker Compose",
"automerge": false
},
{
"matchManagers": ["pip_requirements", "pyproject", "pip_setup"],
"groupName": "Python dependencies",
"automerge": true
},
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchUpdateTypes": ["major"],
"labels": ["major-update"],
"automerge": false
}
]
}
+4 -4
View File
@@ -1,4 +1,4 @@
flask==3.0.3 flask==3.1.3
requests==2.32.3 requests==2.34.2
fritzconnection==1.14.0 fritzconnection==1.15.1
APScheduler==3.10.4 APScheduler==3.11.3