Build KarlDNS self-hosted DynDNS service

This commit is contained in:
StasonJatham committed 2026-05-20 23:55:27 +02:00
1 parent 45bc38d950
commit ca92dc211a
65 files changed
+9200 -3570

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token
def test_shared_secret_is_not_admin_password_fallback(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "secret"))
assert response.status_code == 500
assert response.json()["detail"] == "DDNS_ADMIN_PASSWORD is required"
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_domain_challenge("example.net")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "admin"))
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
assert response.text.count("data-admin-table") == 4
assert response.text.count("data-table-search") == 4
assert response.text.count("data-table-page-size") == 4
assert response.text.count("data-table-pagination") == 4
assert "admin-table-scroll" in response.text
assert "Scheduled cleanup" in response.text
assert "Scheduler state" in response.text
assert "database lease" in response.text
assert "Active credentials" in response.text
assert "Domain claims" in response.text
assert "Recent events" in response.text
assert "example.net" in response.text
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_path="/ops",
)
store = DdnsStore(settings.database_path)
store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
response = client.get("/ops", auth=("admin", "admin"))
assert response.status_code == 200
assert 'href="/ops/records"' in response.text
assert 'href="/ops/events"' in response.text
assert 'action="/ops/cleanup"' in response.text
assert 'action="/ops/accounts/rotate"' in response.text
assert 'action="/admin/cleanup"' not in response.text
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 200
assert client.get("/records", auth=("admin", "admin")).status_code == 404
assert client.get("/admin", auth=("admin", "admin")).status_code == 404
def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
first = store.create_account("first.example.net", "router")
second = store.create_account("second.example.net", "router")
store.upsert(first["hostname"], "203.0.113.1", None)
store.upsert(second["hostname"], "203.0.113.2", None)
store.log_update_event(first["hostname"], "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event(second["hostname"], "203.0.113.2", None, "updated", "ok", "198.51.100.2")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
records = client.get("/records?limit=1&offset=1", auth=("admin", "admin")).json()
events = client.get("/events?limit=1&offset=1", auth=("admin", "admin")).json()
assert records["limit"] == 1
assert records["offset"] == 1
assert records["total"] == 2
assert len(records["items"]) == 1
assert events["limit"] == 1
assert events["offset"] == 1
assert events["total"] == 2
assert len(events["items"]) == 1
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post(
"/admin/cleanup",
auth=("admin", "admin"),
data={},
)
assert response.status_code == 403
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", params={"password": "admin"})
assert response.status_code == 401
def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_rate_limit_per_minute=1,
)
)
client = TestClient(app)
first = client.get("/admin", auth=("admin", "wrong"))
second = client.get("/admin", auth=("admin", "wrong"))
assert first.status_code == 401
assert second.status_code == 429
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
store = DdnsStore(settings.database_path)
account = store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
csrf = admin_csrf_token(settings)
update = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
assert "Update URL rotated" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
assert "Private status page rotated" in management.text
assert "/m/" in management.text
password = client.post(
"/admin/accounts/rotate",
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password rotated" in password.text
assert "One-box update URL:" in password.text
assert "/nic/update?" in password.text
assert "Kennwort (optional):" in password.text
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
assert "admin_update_url_rotated" in statuses
assert "admin_management_url_rotated" in statuses
assert "admin_router_credentials_rotated" in statuses
def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None:
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")
store = DdnsStore(settings.database_path)
store.create_account("home.example.net", "router")
app = make_app(settings)
client = TestClient(app)
response = client.post(
"/admin/accounts/delete",
auth=("admin", "admin"),
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
follow_redirects=False,
)
assert response.status_code == 303
assert store.get_account_by_hostname("home.example.net") is None
events = store.list_update_events_for_hostname("home.example.net", 10)
assert events[0]["status"] == "admin_hostname_deleted"
assert "secret" not in str(events[0]["detail"]).lower()
def test_admin_template_escapes_update_event_html(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_account("home.example.net", "router")
store.log_update_event(
"home.example.net",
"203.0.113.10",
None,
"dns_failed",
'<script>alert("xss")</script>',
"198.51.100.20",
)
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "admin"))
assert response.status_code == 200
assert '<script>alert("xss")</script>' not in response.text
assert "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;" in response.text