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

+127 -83
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi.testclient import TestClient
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from karldns import DdnsSettings, DdnsStore, make_app
def _csrf(html: str) -> str:
@@ -23,14 +23,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
response = client.post("/")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Register URL:" in response.text
assert "Router fields" in response.text
assert "CNAME" in response.text
assert "Manual updates" in response.text
@@ -40,8 +38,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
assert "/u/" in response.text
assert "/nic/update?" in response.text
assert "/api/v1/router/update/" in response.text
assert "/api/v1/router/register/" in response.text
assert "/m/" in response.text
@@ -51,18 +49,18 @@ def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
assert response.status_code == 400
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
assert response.status_code == 404
assert app.state.store.count_accounts() == 0
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
@@ -73,15 +71,16 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
)
client = TestClient(app)
response = client.post("/magic")
response = client.post("/")
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Register URL:" in response.text
assert "Private dashboard:" in response.text
assert "Pending setup" in response.text
assert "Register hostname" in response.text
assert "Unregistered setups are removed after 24 hours." in response.text
assert "Router fields" in response.text
assert "CNAME" in response.text
assert "FRITZ!Box fills in" in response.text
@@ -91,17 +90,64 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
assert ".ddns.example.net" in response.text
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
def test_public_examples_use_visitor_public_ip(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="https://karldns.de",
)
)
client = TestClient(app, client=("8.8.8.8", 12345))
home = client.get("/")
created = client.post("/")
assert home.status_code == 200
assert created.status_code == 200
assert "myip=8.8.8.8" in home.text
assert "good 8.8.8.8" in home.text
assert "myip=8.8.8.8" in created.text
def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/")
management_slug = re.search(r'action="/m/([^"]+)/activate"', created.text).group(1)
account = app.state.store.get_account_by_management_slug(management_slug)
assert account["accepted_at"] is None
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)})
assert activated.status_code == 200
assert "Hostname registered" in activated.text
assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"]
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
def test_old_magic_route_is_gone(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/magic", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/#create"
assert response.status_code == 404
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "home")
@@ -113,8 +159,6 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
assert '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Router fields" in management.text
assert "CNAME" in management.text
assert "Manual updates" in management.text
@@ -126,7 +170,7 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
assert deleted.status_code == 200
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
update = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 404
@@ -170,22 +214,10 @@ def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> N
assert "Update-URL:" not in locked.text
assert "correct horse" not in locked.text
update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
update = locked_client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert update.status_code == 200
assert update.text == "good 203.0.113.9"
nic_update = locked_client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"myip": "203.0.113.10",
"username": account["username"],
"password": account["password"],
},
)
assert nic_update.status_code == 200
assert nic_update.text == "good 203.0.113.10"
bad_unlock = locked_client.post(
f"/m/{account['management_slug']}/unlock",
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
@@ -333,7 +365,7 @@ def test_existing_dashboard_password_requires_current_password_to_change_or_remo
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path: Path) -> None:
def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
@@ -349,7 +381,6 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200
assert "New update URL" in dashboard.text
assert "New password" in dashboard.text
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
@@ -357,26 +388,10 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
assert rotated_update.status_code == 200
assert "Update URL changed. Update your router now." in rotated_update.text
assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
assert rotated_password.status_code == 200
assert "Router password changed" in rotated_password.text
assert "Full update URL:" in rotated_password.text
assert "Kennwort (optional):" in rotated_password.text
assert "data-secret-toggle" in rotated_password.text
assert client.get(
"/nic/update",
params={
"hostname": "home.ddns.example.net",
"username": "router",
"password": account["password"],
"myip": "203.0.113.9",
},
).status_code == 401
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
assert "dashboard_update_url_rotated" in statuses
assert "dashboard_router_credentials_rotated" in statuses
assert "dashboard_router_credentials_rotated" not in statuses
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
@@ -393,8 +408,8 @@ def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -
assert rotated.status_code == 200
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
assert client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
assert client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
@@ -437,30 +452,6 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
assert "Add a CNAME" in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
assert "--delete-config" in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://karldns.de",
)
)
@@ -469,11 +460,61 @@ def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) ->
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/u/&lt;secret&gt;" in response.text
assert 'href="/admin"' not in response.text
assert 'href="#create"' not in response.text
assert response.text.count(">Create hostname<") == 1
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "Create a hostname, save the private values, register it, done." in response.text
assert "Random hostname and private links." in response.text
assert "Activate the hostname when you are ready." in response.text
assert "home.example.com. CNAME" not in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
assert "User install" not in response.text
assert "--delete-config" not in response.text
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="karldns.de",
public_base_url="https://ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://ddns.example.net/api/v1/router/update/&lt;secret&gt;" in response.text
assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" not in response.text
assert "http://127.0.0.1" not in response.text
assert "http://localhost" not in response.text
def test_public_examples_use_karldns_domain_and_no_shell_prompt(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)
response = client.get("/")
assert response.status_code == 200
assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" in response.text
assert "http://127.0.0.1:8099/api/v1/router/update/&lt;secret&gt;" not in response.text
assert "$ curl" not in response.text
assert 'data-copy="curl -sS &#x27;https://karldns.de/api/v1/router/update/&lt;secret&gt;?myip=203.0.113.10&#x27;"' in response.text
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
@@ -485,7 +526,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=5" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=12" rel="stylesheet">' in response.text
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
assert "<style>" not in response.text
@@ -534,6 +575,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert "Made with" in response.text
assert "❤️" in response.text
assert "by karl" in response.text
assert response.text.count('class="lucide-icon footer-icon"') == 5
assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text
assert 'class="lucide-icon copy-icon"' in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text
@@ -546,7 +590,7 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert "WebPage" in privacy.text
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "protected router passwords" in privacy.text
assert "private update links" in privacy.text
assert "Made with" in privacy.text
assert "❤️" in privacy.text
assert "by karl" in privacy.text