Archived
Add private status page IP history
This commit is contained in:
1 parent
2203852170
commit
5948deba47
4 files changed
+67
-5
No files matched your search
@@ -255,6 +255,6 @@ Self-hosted DynDNS, self hosted DDNS, Dynamic DNS server, FRITZ!Box DynDNS, FRIT
|
|||||||
|
|
||||||
## Security
|
## 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.
|
If you find a vulnerability, open a private advisory or contact the repository owner before publishing details.
|
||||||
+31
-2
@@ -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
|
notice = _admin_secret_panel("Update URL rotated", [_copy_row("Update-URL:", service.fritz_update_url(rotated))]) if rotated else None
|
||||||
elif action == "management":
|
elif action == "management":
|
||||||
rotated = store.rotate_management_slug(hostname)
|
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":
|
elif action == "password":
|
||||||
rotated = store.rotate_password(hostname)
|
rotated = store.rotate_password(hostname)
|
||||||
if rotated:
|
if rotated:
|
||||||
@@ -752,7 +752,7 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
|
|||||||
{_copy_row("Domainnamen:", hostname)}
|
{_copy_row("Domainnamen:", hostname)}
|
||||||
{_copy_row("Benutzername:", username)}
|
{_copy_row("Benutzername:", username)}
|
||||||
{_copy_row("Kennwort:", password)}
|
{_copy_row("Kennwort:", password)}
|
||||||
{_copy_row("Magic management link:", management_url)}
|
{_copy_row("Private status page:", management_url)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -814,6 +814,9 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage
|
|||||||
ipv4 = html.escape(str(account.get("ipv4") or "-"))
|
ipv4 = html.escape(str(account.get("ipv4") or "-"))
|
||||||
ipv6 = html.escape(str(account.get("ipv6") or "-"))
|
ipv6 = html.escape(str(account.get("ipv6") or "-"))
|
||||||
updated = html.escape(str(account.get("updated_at") or "Never"))
|
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 """
|
||||||
|
<tr><td colspan="5" class="empty">No router updates yet.</td></tr>
|
||||||
|
"""
|
||||||
return _page(
|
return _page(
|
||||||
"DynDNS management",
|
"DynDNS management",
|
||||||
f"""
|
f"""
|
||||||
@@ -857,6 +860,23 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">History</p>
|
||||||
|
<h2>Router updates</h2>
|
||||||
|
<p class="section-copy">Recent IP changes and failed DNS publishes for this hostname.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Time</th><th>Status</th><th>IPv4</th><th>IPv6</th><th>Detail</th></tr></thead>
|
||||||
|
<tbody>{history_rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="section danger-section">
|
<section class="section danger-section">
|
||||||
<div class="container split-row">
|
<div class="container split-row">
|
||||||
<div>
|
<div>
|
||||||
@@ -1082,6 +1102,15 @@ def _event_row(event: dict[str, str | None]) -> str:
|
|||||||
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{detail}</td></tr>"
|
return f"<tr><td>{created}</td><td>{hostname}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{detail}</td></tr>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<tr><td>{created}</td><td>{status}</td><td>{ipv4}</td><td>{ipv6}</td><td>{detail}</td></tr>"
|
||||||
|
|
||||||
|
|
||||||
def _page(title: str, body: str) -> str:
|
def _page(title: str, body: str) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
|||||||
@@ -332,6 +332,21 @@ class DdnsStore:
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(row) for row in rows]
|
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]]:
|
def list_cleanup_runs(self, limit: int = 20) -> list[dict[str, str | int]]:
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
|||||||
+20
-2
@@ -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"},
|
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
|
||||||
)
|
)
|
||||||
assert management.status_code == 200
|
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
|
assert "/m/" in management.text
|
||||||
|
|
||||||
password = client.post(
|
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 response.status_code == 200
|
||||||
assert "Update-URL:" in response.text
|
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
|
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']}")
|
management = client.get(f"/m/{account['management_slug']}")
|
||||||
assert management.status_code == 200
|
assert management.status_code == 200
|
||||||
assert "home.ddns.example.net" in management.text
|
assert "home.ddns.example.net" in management.text
|
||||||
|
assert "Router updates" in management.text
|
||||||
|
|
||||||
deleted = client.post(f"/m/{account['management_slug']}/delete")
|
deleted = client.post(f"/m/{account['management_slug']}/delete")
|
||||||
assert deleted.status_code == 200
|
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
|
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:
|
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"))
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|||||||
Reference in new issue
Block a user