Prepare KarlDNS production deployment

This commit is contained in:
StasonJatham committed 2026-05-21 17:23:08 +02:00
1 parent 5a90bd9d87
commit ce5b4e0ff2
101 files changed
+3364 -2018

No files matched your search

+225 -124
View File
@@ -1,81 +1,95 @@
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from urllib.parse import urlparse
import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
import karldns.services.ddns as ddns_module
from karldns import DdnsSettings, DdnsStore, DnsProviderError, make_app
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
def _path(url: str) -> str:
parsed = urlparse(url)
return parsed.path
def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=database_path,
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert response.status_code == 201
payload = response.json()
one_box_url = payload["credentialed_update_url"]
parsed = urlparse(one_box_url)
params = parse_qs(parsed.query)
assert parsed.path == "/nic/update"
assert params["hostname"] == [payload["hostname"]]
assert params["username"] == [payload["username"]]
assert params["password"] == [payload["password"]]
assert params["myip"] == ["<ipaddr>"]
assert params["myipv6"] == ["<ip6addr>"]
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
docs = client.get("/docs")
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
assert docs.status_code == 200
assert redoc.status_code == 200
assert docs.headers["x-robots-tag"] == "noindex, nofollow"
assert redoc.headers["x-robots-tag"] == "noindex, nofollow"
assert redoc.status_code == 404
assert "Small API surface." in docs.text
assert "POST /api/v1/hostnames" in docs.text
assert "GET or POST /api/v1/router/register/&lt;secret&gt;" in docs.text
assert "GET or POST /api/v1/router/update/&lt;secret&gt;" in docs.text
assert "GET /api/v1/hostnames/&lt;secret&gt;" in docs.text
assert "POST /api/v1/hostnames/&lt;secret&gt;/rotate-update-url" in docs.text
assert "Rotate update URL" in docs.text
assert "GET /api/v1/admin/dashboard" in docs.text
assert "GET /api/v1/admin/events" in docs.text
assert "https://karldns.de/api/v1/hostnames" in docs.text
assert "http://localhost:8080/api/v1/hostnames" not in docs.text
assert "registration_url" in docs.text
assert "/api/v1/management" not in docs.text
assert "/api/v1/hostnames/magic" not in docs.text
assert "/nic/update" not in docs.text
assert "/u/&lt;secret&gt;" not in docs.text
assert "swagger-ui" not in docs.text.lower()
assert "redoc" not in docs.text.lower()
assert "x-robots-tag" not in docs.headers
assert client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
assert "/api/v1/hostnames/magic" in schema["paths"]
assert "/api/v1/updates/{update_slug}" in schema["paths"]
assert "/u/{update_slug}" in schema["paths"]
assert "/nic/update" in schema["paths"]
assert "/ip" in schema["paths"]
assert "/healthz" in schema["paths"]
assert "cdn.jsdelivr.net" not in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
expected_paths = {
"/api/v1/admin/dashboard",
"/api/v1/admin/events",
"/api/v1/admin/hostnames",
"/api/v1/admin/records",
"/api/v1/hostnames",
"/api/v1/hostnames/{dashboard_secret}",
"/api/v1/hostnames/{dashboard_secret}/rotate-update-url",
"/api/v1/router/register/{dashboard_secret}",
"/api/v1/router/update/{update_slug}",
}
assert set(schema["paths"]) == expected_paths
assert "/" not in schema["paths"]
assert "/admin" not in schema["paths"]
assert "/healthz" not in schema["paths"]
assert "/records" not in schema["paths"]
assert "/events" not in schema["paths"]
assert "/" not in schema["paths"]
assert "/magic" not in schema["paths"]
assert "/accounts" not in schema["paths"]
assert "/m/{management_slug}" not in schema["paths"]
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"])
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
assert "Dashboard passwords protect only the web dashboard" in management_tag["description"]
management_get = schema["paths"]["/api/v1/management/{management_slug}"]["get"]
management_delete = schema["paths"]["/api/v1/management/{management_slug}"]["delete"]
assert "Dashboard passwords protect only the web dashboard" in management_get["description"]
assert "Dashboard passwords protect only the web dashboard" in management_delete["description"]
assert any(tag["name"] == "router" for tag in schema["tags"])
assert any(tag["name"] == "admin" for tag in schema["tags"])
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
def test_custom_api_docs_use_visitor_public_ip(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
)
)
client = TestClient(app, client=("8.8.4.4", 12345))
response = client.get("/docs")
assert response.status_code == 200
assert "myip=8.8.4.4" in response.text
def test_openapi_hides_custom_operator_routes(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -85,78 +99,38 @@ def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
)
client = TestClient(app)
redoc = client.get("/redoc")
schema = client.get("/openapi.json").json()
forbidden_paths = {
"/ops",
"/ops/accounts/delete",
"/ops/accounts/rotate",
"/ops/hostnames/delete",
"/ops/hostnames/rotate",
"/ops/cleanup",
"/ops/events",
"/ops/records",
"/records",
"/events",
}
assert redoc.status_code == 200
assert client.get("/redoc").status_code == 404
assert forbidden_paths.isdisjoint(schema["paths"])
assert all("admin" not in tag["name"].lower() for tag in schema["tags"])
assert "/api/v1/admin/dashboard" in schema["paths"]
def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> None:
def test_openapi_marks_generated_links_as_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
schema = client.get("/openapi.json").json()
created = schema["components"]["schemas"]["HostnameCreateResponse"]["properties"]
status = schema["components"]["schemas"]["HostnameStatusResponse"]["properties"]
expected_public_paths = {
"/api/v1/hostnames/magic",
"/api/v1/management/{management_slug}",
"/api/v1/updates/{update_slug}",
"/healthz",
"/ip",
"/nic/update",
"/u/{update_slug}",
}
assert set(schema["paths"]) == expected_public_paths
for name in ["update_url", "dashboard_url", "registration_url"]:
assert "Secret" in created[name]["description"]
for name in ["update_url", "dashboard_url"]:
assert "Secret" in status[name]["description"]
def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
schema = client.get("/openapi.json").json()
credentials = schema["components"]["schemas"]["HostnameCredentials"]["properties"]
managed = schema["components"]["schemas"]["ManagedRecord"]["properties"]
for name in ["password", "update_url", "credentialed_update_url", "management_url"]:
assert "Secret" in credentials[name]["description"]
assert "Secret" in managed["update_url"]["description"]
@pytest.mark.parametrize(
("path", "payload"),
[
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
],
)
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post(path, json=payload)
assert response.status_code == 422
assert response.json()["detail"][0]["type"] == "extra_forbidden"
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -167,38 +141,165 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
)
client = TestClient(app)
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
created = client.post("/api/v1/hostnames")
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
assert created.headers["x-robots-tag"] == "noindex, nofollow"
body = created.json()
assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/")
assert body["management_url"].startswith("http://ddns.example.net/m/")
assert body["status"] == "pending"
assert body["update_url"].startswith("http://ddns.example.net/api/v1/router/update/")
assert body["dashboard_url"].startswith("http://ddns.example.net/m/")
assert body["registration_url"].startswith("http://ddns.example.net/api/v1/router/register/")
management_slug = body["management_url"].rsplit("/", 1)[1]
managed = client.get(f"/api/v1/management/{management_slug}")
assert managed.status_code == 200
assert managed.json()["hostname"] == body["hostname"]
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
update_path = _path(body["update_url"])
register_path = _path(body["registration_url"])
deleted = client.delete(f"/api/v1/management/{management_slug}")
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
assert pending.status_code == 200
assert pending.json()["status"] == "pending"
assert pending.json()["history"] == []
assert client.get(update_path, params={"myip": "203.0.113.11"}).status_code == 404
registered = client.post(register_path, params={"myip": "203.0.113.10"})
assert registered.status_code == 200
assert registered.text == "good registered"
update = client.get(update_path, params={"myip": "203.0.113.11"})
assert update.status_code == 200
assert update.text == "good 203.0.113.11"
active = client.get(f"/api/v1/hostnames/{dashboard_secret}", params={"history_limit": 10})
assert active.status_code == 200
payload = active.json()
assert payload["status"] == "active"
assert payload["ipv4"] == "203.0.113.11"
assert payload["history"][0]["hostname"] == body["hostname"]
assert "source_ip" not in payload["history"][0]
deleted = client.delete(f"/api/v1/hostnames/{dashboard_secret}")
assert deleted.status_code == 204
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
assert client.get(f"/api/v1/hostnames/{dashboard_secret}").status_code == 404
def test_hostname_api_can_rotate_update_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
body = client.post("/api/v1/hostnames").json()
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
old_update_path = _path(body["update_url"])
pending_rotate = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.10"})
rotated = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
new_update_path = _path(rotated.json()["update_url"])
assert pending_rotate.status_code == 400
assert registered.status_code == 200
assert rotated.status_code == 200
assert rotated.json()["hostname"] == body["hostname"]
assert rotated.json()["update_url"] != body["update_url"]
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404
assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
def test_hostname_api_history_is_scoped_to_dashboard_secret(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
own = store.create_account("own.ddns.example.net", "own-router")
other = store.create_account("other.ddns.example.net", "other-router")
store.log_update_event("own.ddns.example.net", "203.0.113.21", None, "good", "updated", "198.51.100.10")
store.log_update_event("other.ddns.example.net", "203.0.113.22", None, "good", "updated", "198.51.100.11")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/hostnames/{own['management_slug']}", params={"history_limit": 10})
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
payload = response.json()
assert len(payload["history"]) == 1
assert payload["history"][0]["hostname"] == "own.ddns.example.net"
assert "source_ip" not in payload["history"][0]
assert "other.ddns.example.net" not in response.text
assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422
assert client.get(f"/api/v1/hostnames/{'a' * 32}").status_code == 404
def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
)
)
client = TestClient(app)
body = client.post("/api/v1/hostnames").json()
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.77"})
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
assert registered.status_code == 200
assert status.json()["status"] == "active"
assert status.json()["ipv4"] == "203.0.113.77"
assert len(published) == 1
assert published[0].hostname == body["hostname"]
assert published[0].ipv4 == "203.0.113.77"
def test_registration_requires_ip_when_provider_source_ip_is_not_public(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: None)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
)
)
client = TestClient(app)
body = client.post("/api/v1/hostnames").json()
registered = client.post(_path(body["registration_url"]))
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
assert registered.status_code == 400
assert registered.text.startswith("badip registration needs a public source IP")
assert status.json()["status"] == "pending"
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
published = []
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 200
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
assert response.text == "good 203.0.113.11"
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
@@ -207,14 +308,14 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
def fail_publish(result, settings):
raise DnsProviderError("dns down")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
assert response.status_code == 500
assert store.list_records() == []
@@ -232,8 +333,8 @@ def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
)
client = TestClient(app)
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
first = client.post("/api/v1/hostnames")
second = client.post("/api/v1/hostnames")
assert first.status_code == 201
assert second.status_code == 429
@@ -246,8 +347,8 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
assert response.status_code == 400
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
assert response.text == "badip"
assert store.list_records() == []