Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+149 -3
View File
@@ -1,3 +1,5 @@
import hashlib
import hmac
import logging
from pathlib import Path
@@ -5,8 +7,9 @@ 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
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
from router_dyndns.ddns_dns import validate_dns_provider_settings
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
@@ -74,11 +77,92 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
)
client = TestClient(app)
first = client.get("/m/not-a-real-token")
second = client.get("/m/another-token")
first = client.get(f"/m/{'a' * 43}")
second = client.get(f"/m/{'b' * 43}")
assert first.status_code == 404
assert second.status_code == 429
assert app.state.store.get_service_metrics()["rate_limit_hits_total"] == 1
def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path, caplog) -> None:
caplog.set_level(logging.INFO, logger="router_dyndns.access")
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
redacted_access_log=True,
)
)
account = app.state.store.create_account("home.example.net", "router")
client = TestClient(app)
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.1"})
client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"username": account["username"],
"password": account["password"],
"myip": "203.0.113.2",
},
)
assert "/u/<secret>" in caplog.text
assert "/nic/update?<redacted>" in caplog.text
assert account["update_slug"] not in caplog.text
assert account["password"] not in caplog.text
@pytest.mark.parametrize(
"path",
[
"/u/short",
"/api/v1/updates/short",
"/m/short",
"/api/v1/management/short",
"/d/short",
],
)
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get(path)
assert response.status_code == 422
def test_dashboard_unlock_cookie_rejects_password_hash_signed_forgery(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
store.set_dashboard_password("home.example.net", "correct horse")
settings = DdnsSettings(
database_path=database_path,
admin_password="admin",
dashboard_cookie_secret="s" * 32,
)
app = make_app(settings)
client = TestClient(app)
stored = store.get_account_by_management_slug(account["management_slug"])
password_hash = str(stored["dashboard_password_hash"])
timestamp = 1_900_000_000
max_age = settings.dashboard_unlock_seconds
payload = f"{account['hostname']}\0{account['management_slug']}\0{timestamp}\0{max_age}".encode()
forged = f"v2:{timestamp}:{max_age}:{hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()}"
client.cookies.set(DASHBOARD_UNLOCK_COOKIE, forged, path=f"/m/{account['management_slug']}")
response = client.get(f"/m/{account['management_slug']}")
assert response.status_code == 200
assert "Dashboard locked" in response.text
assert "Update-URL:" not in response.text
def test_settings_reject_unknown_constructor_fields() -> None:
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
DdnsSettings(admn_password="typo")
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
@@ -246,6 +330,40 @@ def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) ->
)
def test_required_dns_provider_requires_strong_dashboard_cookie_signing_source(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_DASHBOARD_COOKIE_SECRET"):
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
rfc2136_zone="example.net",
require_dns_provider=True,
)
)
def test_required_dns_provider_accepts_explicit_dashboard_cookie_secret(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
dashboard_cookie_secret="s" * 32,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
rfc2136_zone="example.net",
require_dns_provider=True,
)
)
assert app.state.settings.dashboard_cookie_secret == "s" * 32
def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
make_app(
@@ -261,6 +379,34 @@ def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> N
)
def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
import router_dyndns.dns.cloudflare as cloudflare
calls = {"find": 0}
def fake_find(settings, hostname, record_type):
calls["find"] += 1
if calls["find"] == 1:
return None
return {"id": "record-id", "content": "203.0.113.99"}
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **kwargs: {"success": True, "result": []})
with pytest.raises(DnsReconciliationError, match="did not match"):
cloudflare.publish_cloudflare(
UpdateResult(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
changed=True,
update_ipv4=True,
update_ipv6=False,
),
DdnsSettings(cloudflare_api_token="token", cloudflare_zone_id="zone"),
)
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
import dns.query
import dns.rcode