mirror of
https://github.com/skoelle/dyndns-updater.git
synced 2026-09-18 08:30:24 +00:00
Compare commits
7
Commits
43f2e96720
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45861b091e | ||
|
|
abd734b2dd | ||
|
|
21cfd1aaaa | ||
|
|
7c3bcd9787 | ||
|
|
83817884f8 | ||
|
|
539e0e5853 | ||
|
|
70efc66696 |
@@ -21,6 +21,14 @@ POLL_INTERVAL_MINUTES=15
|
||||
# Healthchecks (self-hosted)
|
||||
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
|
||||
TZ=Europe/Berlin
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
@@ -14,25 +14,25 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/dyndns-updater
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern={{version}}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
@@ -9,8 +9,8 @@ jobs:
|
||||
ruff:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install ruff
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
title: "DynDns Updater"
|
||||
emoji: "📡"
|
||||
category: code
|
||||
subcategory: "Infra Tools"
|
||||
status: active
|
||||
stack: [Python, Flask, Requests, fritzconnection, APScheduler]
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
## Phase 2 - Kernmodule
|
||||
- [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
|
||||
- [ ] .env lokal befuellen (nicht committen)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# dyndns-updater
|
||||
# 🌐 dyndns-updater
|
||||
|
||||
Kleiner Docker-Container, der Cloudflare-A-Records (DNS-only) und eine bestehende
|
||||
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)
|
||||
(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)
|
||||
- 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
|
||||
- DNS-Update (Cloudflare + FreeDNS) nur bei tatsächlicher IP-Änderung
|
||||
- Healthcheck-Heartbeat bei jedem Poll-Zyklus, unabhängig von einer IP-Änderung
|
||||
- 🔄 DNS-Update (Cloudflare + FreeDNS) nur bei tatsächlicher IP-Änderung
|
||||
- 💚 Healthcheck-Heartbeat bei jedem Poll-Zyklus, unabhängig von einer IP-Änderung
|
||||
|
||||
## Quickstart
|
||||
## 🚀 Quickstart
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -23,34 +23,48 @@ vim .env # Werte eintragen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Konfiguration (`.env`)
|
||||
## ⚙️ Konfiguration (`.env`)
|
||||
|
||||
Alle Einstellungen erfolgen ausschließlich über Umgebungsvariablen (siehe
|
||||
[`.env.example`](.env.example)). Pflichtfelder:
|
||||
|
||||
| Variable | Zweck |
|
||||
| --- | --- |
|
||||
| `FRITZBOX_HOST`, `_USER`, `_PASSWORD` | TR-064-Zugang zur FritzBox (IP-Quelle) |
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare-Token (Zone -> DNS -> Edit) |
|
||||
| `CLOUDFLARE_ZONE_ID` | Cloudflare-Zone |
|
||||
| `CLOUDFLARE_RECORDS` | Kommagetrennte A-Record-Namen (DNS-only) |
|
||||
| `FRITZBOX_HOST`, `_USER`, `_PASSWORD` | 🏠 TR-064-Zugang zur FritzBox (IP-Quelle) |
|
||||
| `CLOUDFLARE_API_TOKEN` | ☁️ Cloudflare-Token (Zone -> DNS -> Edit) |
|
||||
| `CLOUDFLARE_ZONE_ID` | ☁️ Cloudflare-Zone |
|
||||
| `CLOUDFLARE_RECORDS` | 📝 Kommagetrennte A-Record-Namen (DNS-only) |
|
||||
|
||||
Optional: `FREEDNS_UPDATE_URL`, `POLL_INTERVAL_MINUTES`,
|
||||
`HEALTHCHECK_PING_URL`, `TZ`, `LOG_LEVEL`. Die IP kommt ausschließlich aus der
|
||||
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
|
||||
der State wieder leer, wodurch beim Start ein Force-Push erzwungen wird.
|
||||
Die Änderung wird erst nach erfolgreichem Update gespeichert.
|
||||
- **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`).
|
||||
- **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).
|
||||
- **🪝 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`).
|
||||
- **🏥 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).
|
||||
|
||||
## Test/Quality
|
||||
## 🧪 Test/Quality
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && . .venv/bin/activate # virtuelles Umfeld
|
||||
@@ -59,11 +73,11 @@ ruff check .
|
||||
ruff format --check .
|
||||
```
|
||||
|
||||
## Build
|
||||
## 🏗️ Build
|
||||
|
||||
Wird automatisch per GitHub Actions nach `ghcr.io/skoelle/dyndns-updater` gebaut
|
||||
(siehe `.github/workflows/build-and-push.yml`).
|
||||
|
||||
## License
|
||||
## 📄 License
|
||||
|
||||
Licensed under the [MIT License](LICENSE) - Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
|
||||
|
||||
@@ -26,7 +26,8 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle.
|
||||
2. IP_letzte = state.load()
|
||||
3. Wenn gleich: Poll -> nur Heartbeat-Ping, kein Update. Webhook/Start -> nur Log.
|
||||
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
|
||||
|
||||
@@ -46,7 +47,14 @@ die IP bei jedem Trigger selbst ueber die FritzBox TR-064-Schnittstelle.
|
||||
- FritzBox-TR-064-User mit eingeschraenkten Rechten
|
||||
- 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
|
||||
- Proxy-Mode fuer reine HTTP(S)-Subdomains
|
||||
|
||||
@@ -39,8 +39,19 @@ class Config:
|
||||
|
||||
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")
|
||||
|
||||
@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):
|
||||
missing = [v for v in REQUIRED_VARS if not os.environ.get(v)]
|
||||
if missing:
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ import threading
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
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.fritzbox import FritzBoxError, get_external_ip
|
||||
|
||||
@@ -67,6 +67,7 @@ def run_cycle(trigger: str = "unknown"):
|
||||
status="success",
|
||||
message=f"IP updated to {current_ip}",
|
||||
)
|
||||
notify.send_ip_changed_email(last_ip, current_ip)
|
||||
else:
|
||||
failed = {k: v for k, v in cf_results.items() if v != "ok"}
|
||||
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
|
||||
@@ -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
@@ -1,4 +1,4 @@
|
||||
flask==3.0.3
|
||||
requests==2.32.3
|
||||
fritzconnection==1.14.0
|
||||
APScheduler==3.10.4
|
||||
flask==3.1.3
|
||||
requests==2.34.2
|
||||
fritzconnection==1.15.1
|
||||
APScheduler==3.11.3
|
||||
|
||||
Reference in New Issue
Block a user