From 5948deba47b79a50cc4af80cd28fb945f9e2e740 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 18:37:14 +0200 Subject: [PATCH] Add private status page IP history --- README.md | 2 +- router_dyndns/ddns.py | 33 +++++++++++++++++++++++++++++++-- router_dyndns/ddns_store.py | 15 +++++++++++++++ tests/test_ddns.py | 22 ++++++++++++++++++++-- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4a17c8e..32ede24 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,6 @@ Self-hosted DynDNS, self hosted DDNS, Dynamic DNS server, FRITZ!Box DynDNS, FRIT ## Security -The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, database, magic management link, domain claim URL, or router-generated update URL. +The repository intentionally contains only placeholders in `.env.example` and documentation. Do not commit a real `.env`, DNS token, TSIG secret, admin password, database, private status page URL, domain claim URL, or router-generated update URL. If you find a vulnerability, open a private advisory or contact the repository owner before publishing details. diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index 20d9c92..ad12a23 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -392,7 +392,7 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None elif action == "management": rotated = store.rotate_management_slug(hostname) - notice = _admin_secret_panel("Management link rotated", [_copy_row("Magic management link:", service.magic_management_url(rotated))]) if rotated else None + notice = _admin_secret_panel("Private status page rotated", [_copy_row("Private status page:", service.magic_management_url(rotated))]) if rotated else None elif action == "password": rotated = store.rotate_password(hostname) if rotated: @@ -752,7 +752,7 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N {_copy_row("Domainnamen:", hostname)} {_copy_row("Benutzername:", username)} {_copy_row("Kennwort:", password)} - {_copy_row("Magic management link:", management_url)} + {_copy_row("Private status page:", management_url)} @@ -814,6 +814,9 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage ipv4 = html.escape(str(account.get("ipv4") or "-")) ipv6 = html.escape(str(account.get("ipv6") or "-")) updated = html.escape(str(account.get("updated_at") or "Never")) + history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 20)) or """ + No router updates yet. + """ return _page( "DynDNS management", f""" @@ -857,6 +860,23 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage +
+
+
+
+

History

+

Router updates

+

Recent IP changes and failed DNS publishes for this hostname.

+
+
+
+ + + {history_rows} +
TimeStatusIPv4IPv6Detail
+
+
+
@@ -1082,6 +1102,15 @@ def _event_row(event: dict[str, str | None]) -> str: return f"{created}{hostname}{status}{ipv4}{ipv6}{detail}" +def _user_event_row(event: dict[str, str | None]) -> str: + created = html.escape(str(event.get("created_at") or "-")) + status = html.escape(str(event.get("status") or "-")) + ipv4 = html.escape(str(event.get("ipv4") or "-")) + ipv6 = html.escape(str(event.get("ipv6") or "-")) + detail = html.escape(str(event.get("detail") or "-")) + return f"{created}{status}{ipv4}{ipv6}{detail}" + + def _page(title: str, body: str) -> str: return f""" diff --git a/router_dyndns/ddns_store.py b/router_dyndns/ddns_store.py index 25f5151..ff3fdb1 100644 --- a/router_dyndns/ddns_store.py +++ b/router_dyndns/ddns_store.py @@ -332,6 +332,21 @@ class DdnsStore: ).fetchall() return [dict(row) for row in rows] + def list_update_events_for_hostname(self, hostname: str, limit: int = 25) -> list[dict[str, str | None]]: + with self.connect() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT hostname, ipv4, ipv6, status, detail, source_ip, created_at + FROM update_events + WHERE hostname = ? + ORDER BY created_at DESC + LIMIT ? + """, + (hostname, limit), + ).fetchall() + return [dict(row) for row in rows] + def list_cleanup_runs(self, limit: int = 20) -> list[dict[str, str | int]]: with self.connect() as conn: conn.row_factory = sqlite3.Row diff --git a/tests/test_ddns.py b/tests/test_ddns.py index 71f99ce..8711743 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -256,7 +256,7 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None: data={"csrf": csrf, "hostname": "home.example.net", "action": "management"}, ) assert management.status_code == 200 - assert "Management link rotated" in management.text + assert "Private status page rotated" in management.text assert "/m/" in management.text password = client.post( @@ -304,7 +304,7 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_ assert response.status_code == 200 assert "Update-URL:" in response.text - assert "Magic management link:" in response.text + assert "Private status page:" in response.text assert ".ddns.example.net" in response.text @@ -318,6 +318,7 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None: management = client.get(f"/m/{account['management_slug']}") assert management.status_code == 200 assert "home.ddns.example.net" in management.text + assert "Router updates" in management.text deleted = client.post(f"/m/{account['management_slug']}/delete") assert deleted.status_code == 200 @@ -326,6 +327,23 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None: assert update.status_code == 404 +def test_management_page_shows_ip_history(tmp_path: Path) -> None: + database_path = tmp_path / "ddns.sqlite3" + store = DdnsStore(database_path) + account = store.create_account("home.ddns.example.net", "router") + store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1") + store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1") + app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) + client = TestClient(app) + + response = client.get(f"/m/{account['management_slug']}") + + assert response.status_code == 200 + assert "Router updates" in response.text + assert "203.0.113.9" in response.text + assert "203.0.113.10" in response.text + + def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None: app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) client = TestClient(app)