Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -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 "<script>alert("xss")</script>" in response.text
|
||||
Reference in new issue
Block a user