Compare commits

..
7 Commits
Author SHA1 Message Date
stefankoelle 45861b091e README.md 2026-08-15 00:00:32 +02:00
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
12 changed files with 197 additions and 35 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
+6
View File
@@ -0,0 +1,6 @@
title: "DynDns Updater"
emoji: "📡"
category: code
subcategory: "Infra Tools"
status: active
stack: [Python, Flask, Requests, fritzconnection, APScheduler]
+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)
+35 -21
View File
@@ -1,4 +1,4 @@
# dyndns-updater # 🌐 dyndns-updater
Kleiner Docker-Container, der Cloudflare-A-Records (DNS-only) und eine bestehende Kleiner Docker-Container, der Cloudflare-A-Records (DNS-only) und eine bestehende
FreeDNS-Update-URL mit der aktuellen öffentlichen IPv4-Adresse aktuell hält. FreeDNS-Update-URL mit der aktuellen öffentlichen IPv4-Adresse aktuell hält.
@@ -6,16 +6,16 @@ FreeDNS-Update-URL mit der aktuellen öffentlichen IPv4-Adresse aktuell hält.
Details siehe [SPEC.md](SPEC.md) (Architektur/Design) und [PLAN.md](PLAN.md) Details siehe [SPEC.md](SPEC.md) (Architektur/Design) und [PLAN.md](PLAN.md)
(Umsetzungs-Tasks). (Umsetzungs-Tasks).
## Kurzüberblick ## 📋 Kurzüberblick
- IP wird ausschließlich über die FritzBox TR-064-Schnittstelle ermittelt (nicht aus - 🏠 IP wird ausschließlich über die FritzBox TR-064-Schnittstelle ermittelt (nicht aus
Webhook-Parametern übernommen) Webhook-Parametern übernommen)
- Trigger: Container-Start, `GET /webhook/update` (kein Auth, rein intern), - Trigger: Container-Start, `GET /webhook/update` (kein Auth, rein intern),
Fallback-Polling alle 15 Minuten Fallback-Polling alle 15 Minuten
- DNS-Update (Cloudflare + FreeDNS) nur bei tatsächlicher IP-Änderung - 🔄 DNS-Update (Cloudflare + FreeDNS) nur bei tatsächlicher IP-Änderung
- Healthcheck-Heartbeat bei jedem Poll-Zyklus, unabhängig von einer IP-Änderung - 💚 Healthcheck-Heartbeat bei jedem Poll-Zyklus, unabhängig von einer IP-Änderung
## Quickstart ## 🚀 Quickstart
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -23,34 +23,48 @@ vim .env # Werte eintragen
docker compose up -d docker compose up -d
``` ```
## Konfiguration (`.env`) ## ⚙️ Konfiguration (`.env`)
Alle Einstellungen erfolgen ausschließlich über Umgebungsvariablen (siehe Alle Einstellungen erfolgen ausschließlich über Umgebungsvariablen (siehe
[`.env.example`](.env.example)). Pflichtfelder: [`.env.example`](.env.example)). Pflichtfelder:
| Variable | Zweck | | Variable | Zweck |
| --- | --- | | --- | --- |
| `FRITZBOX_HOST`, `_USER`, `_PASSWORD` | TR-064-Zugang zur FritzBox (IP-Quelle) | | `FRITZBOX_HOST`, `_USER`, `_PASSWORD` | 🏠 TR-064-Zugang zur FritzBox (IP-Quelle) |
| `CLOUDFLARE_API_TOKEN` | Cloudflare-Token (Zone -> DNS -> Edit) | | `CLOUDFLARE_API_TOKEN` | ☁️ Cloudflare-Token (Zone -> DNS -> Edit) |
| `CLOUDFLARE_ZONE_ID` | Cloudflare-Zone | | `CLOUDFLARE_ZONE_ID` | ☁️ Cloudflare-Zone |
| `CLOUDFLARE_RECORDS` | Kommagetrennte A-Record-Namen (DNS-only) | | `CLOUDFLARE_RECORDS` | 📝 Kommagetrennte A-Record-Namen (DNS-only) |
Optional: `FREEDNS_UPDATE_URL`, `POLL_INTERVAL_MINUTES`, 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.
## Betrieb ### 📧 E-Mail-Notification (optional)
- **State (bewusst ohne Volume, ephemeral):** zuletzt bekannte IP liegt als 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
- **💾 State (bewusst ohne Volume, ephemeral):** zuletzt bekannte IP liegt als
`data/last-known-ip.json` im Container. Bei jedem neu erstellten Container ist `data/last-known-ip.json` im Container. Bei jedem neu erstellten Container ist
der State wieder leer, wodurch beim Start ein Force-Push erzwungen wird. der State wieder leer, wodurch beim Start ein Force-Push erzwungen wird.
Die Änderung wird erst nach erfolgreichem Update gespeichert. Die Änderung wird erst nach erfolgreichem Update gespeichert.
- **Webhook:** `GET /webhook/update` (kein Auth, intern, Port fest 8090) löst einen Zyklus aus. - **🪝 Webhook:** `GET /webhook/update` (kein Auth, intern, Port fest 8090) löst einen Zyklus aus.
- **Healthz:** `GET /healthz` für den Container-Healthcheck (genutzt vom Docker-`HEALTHCHECK`). - **💓 Healthz:** `GET /healthz` für den Container-Healthcheck (genutzt vom Docker-`HEALTHCHECK`).
- **Healthchecks:** Healthy Ping bei jedem Poll, Failure Ping bei IP-Fehler bzw. Teilfehler. - **🏥 Healthchecks:** Healthy Ping bei jedem Poll, Failure Ping bei IP-Fehler bzw. Teilfehler.
- **Logs/Wartung:** `docker compose logs -f`; Updates via Watchtower (Label im Compose). - **📊 Logs/Wartung:** `docker compose logs -f`; Updates via Watchtower (Label im Compose).
## Test/Quality ## 🧪 Test/Quality
```bash ```bash
python3 -m venv .venv && . .venv/bin/activate # virtuelles Umfeld python3 -m venv .venv && . .venv/bin/activate # virtuelles Umfeld
@@ -59,11 +73,11 @@ ruff check .
ruff format --check . ruff format --check .
``` ```
## Build ## 🏗️ Build
Wird automatisch per GitHub Actions nach `ghcr.io/skoelle/dyndns-updater` gebaut Wird automatisch per GitHub Actions nach `ghcr.io/skoelle/dyndns-updater` gebaut
(siehe `.github/workflows/build-and-push.yml`). (siehe `.github/workflows/build-and-push.yml`).
## License ## 📄 License
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
+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