Simplify pending setup secrets

This commit is contained in:
StasonJatham committed 2026-05-21 18:30:15 +02:00
1 parent 5fee087b60
commit 8f07bbde84
6 files changed
+29 -23

No files matched your search

+5
View File
@@ -15,6 +15,7 @@ from .settings import DdnsSettings
DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock" DASHBOARD_UNLOCK_COOKIE = "ddns_dashboard_unlock"
DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60 DASHBOARD_UNLOCK_DEFAULT_SECONDS = 24 * 60 * 60
DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60 DASHBOARD_UNLOCK_STAY_SIGNED_IN_SECONDS = 30 * 24 * 60 * 60
BEARER_SECRET_PREFIX = "karl_"
PRIVATE_PROXY_NETWORKS = tuple( PRIVATE_PROXY_NETWORKS = tuple(
ip_network(network) ip_network(network)
for network in ( for network in (
@@ -62,6 +63,10 @@ def hash_lookup_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest() return hashlib.sha256(token.encode("utf-8")).hexdigest()
def new_bearer_secret() -> str:
return f"{BEARER_SECRET_PREFIX}{secrets.token_urlsafe(32)}"
def sign_dashboard_unlock_cookie( def sign_dashboard_unlock_cookie(
hostname: str, hostname: str,
management_slug: str, management_slug: str,
+5 -6
View File
@@ -1,17 +1,16 @@
from __future__ import annotations from __future__ import annotations
import secrets
from datetime import UTC, datetime from datetime import UTC, datetime
from ..security import hash_lookup_token, hash_secret, verify_secret from ..security import hash_lookup_token, hash_secret, new_bearer_secret, verify_secret
from .types import AccountRow from .types import AccountRow
class AccountsMixin: class AccountsMixin:
def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow: def create_account(self, hostname: str, username: str | None = None, *, accepted: bool = True) -> AccountRow:
now = datetime.now(UTC).isoformat() now = datetime.now(UTC).isoformat()
update_slug = secrets.token_urlsafe(32) update_slug = new_bearer_secret()
management_slug = secrets.token_urlsafe(32) management_slug = new_bearer_secret()
management_hash = hash_lookup_token(management_slug) management_hash = hash_lookup_token(management_slug)
accepted_at = now if accepted else None accepted_at = now if accepted else None
with self.connect() as conn: with self.connect() as conn:
@@ -103,13 +102,13 @@ class AccountsMixin:
conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,)) conn.execute("DELETE FROM records WHERE hostname = ?", (hostname,))
def rotate_update_slug(self, hostname: str) -> AccountRow | None: def rotate_update_slug(self, hostname: str) -> AccountRow | None:
update_slug = secrets.token_urlsafe(32) update_slug = new_bearer_secret()
with self.connect() as conn: with self.connect() as conn:
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname)) conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (update_slug, hostname))
return self.get_account_by_hostname(hostname) return self.get_account_by_hostname(hostname)
def rotate_management_slug(self, hostname: str) -> AccountRow | None: def rotate_management_slug(self, hostname: str) -> AccountRow | None:
management_slug = secrets.token_urlsafe(32) management_slug = new_bearer_secret()
management_hash = hash_lookup_token(management_slug) management_hash = hash_lookup_token(management_slug)
with self.connect() as conn: with self.connect() as conn:
conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname)) conn.execute("UPDATE accounts SET management_hash = ? WHERE hostname = ?", (management_hash, hostname))
+3 -4
View File
@@ -1,10 +1,9 @@
from __future__ import annotations from __future__ import annotations
import secrets
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
from ..security import hash_lookup_token from ..security import hash_lookup_token, new_bearer_secret
BUSY_TIMEOUT_MS = 10_000 BUSY_TIMEOUT_MS = 10_000
CACHE_SIZE_KIB = 20_000 CACHE_SIZE_KIB = 20_000
@@ -160,11 +159,11 @@ def initialize_schema(conn: sqlite3.Connection) -> None:
if schema_version < 2: if schema_version < 2:
conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL") conn.execute("UPDATE accounts SET accepted_at = created_at WHERE accepted_at IS NULL")
for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"): for row in conn.execute("SELECT hostname FROM accounts WHERE update_slug IS NULL OR update_slug = ''"):
conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (secrets.token_urlsafe(32), row[0])) conn.execute("UPDATE accounts SET update_slug = ? WHERE hostname = ?", (new_bearer_secret(), row[0]))
for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"): for row in conn.execute("SELECT hostname FROM accounts WHERE management_hash IS NULL OR management_hash = ''"):
conn.execute( conn.execute(
"UPDATE accounts SET management_hash = ? WHERE hostname = ?", "UPDATE accounts SET management_hash = ? WHERE hostname = ?",
(hash_lookup_token(secrets.token_urlsafe(32)), row[0]), (hash_lookup_token(new_bearer_secret()), row[0]),
) )
_migrate_accounts_schema(conn) _migrate_accounts_schema(conn)
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)") conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_update_slug ON accounts(update_slug)")
+3 -6
View File
@@ -162,7 +162,6 @@ def _render_pending_management_page(
"management_slug": management_slug, "management_slug": management_slug,
} }
update_url = service.fritz_update_url(account_for_url) update_url = service.fritz_update_url(account_for_url)
registration_url = service.registration_url({"management_slug": management_slug})
dashboard_url = service.dashboard_url({"management_slug": management_slug}) dashboard_url = service.dashboard_url({"management_slug": management_slug})
hostname = escape(raw_hostname) hostname = escape(raw_hostname)
message_html = _management_message(message) message_html = _management_message(message)
@@ -188,15 +187,13 @@ def _render_pending_management_page(
<div> <div>
<p class="eyebrow">Save</p> <p class="eyebrow">Save</p>
<h2>Your values.</h2> <h2>Your values.</h2>
<p class="section-copy">This page is private. You can revisit it while the setup is pending. After registration, the dashboard hides secrets by default.</p> <p class="section-copy">Save the update URL and dashboard link before you register. After registration, the dashboard hides secrets by default.</p>
<p class="section-copy"><strong>Public:</strong> hostname and CNAME target. <strong>Secret:</strong> update URL, register URL, and private dashboard.</p> <p class="section-copy"><strong>Public:</strong> Domainnamen. <strong>Secret:</strong> Update-URL and private dashboard.</p>
</div> </div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)} {_copy_row("Update-URL:", update_url, "Secret", mask_secret=False)}
{_copy_row("Domainnamen:", raw_hostname, "Public")} {_copy_row("Domainnamen:", raw_hostname, "Public")}
{_copy_row("Register URL:", registration_url, "Secret", mask_secret=False)}
{_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)} {_copy_row("Private dashboard:", dashboard_url, "Secret", mask_secret=False)}
{_copy_row("CNAME target:", raw_hostname, "Public")}
</div> </div>
<form method="post" action="/m/{attr(management_slug)}/activate"> <form method="post" action="/m/{attr(management_slug)}/activate">
<input type="hidden" name="csrf" value="{attr(csrf_token)}"> <input type="hidden" name="csrf" value="{attr(csrf_token)}">
@@ -219,7 +216,7 @@ def _render_pending_management_page(
</div> </div>
<div> <div>
<h2>Use your own DNS name.</h2> <h2>Use your own DNS name.</h2>
<p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. KarlDNS only updates this generated hostname.</p> <p class="section-copy">Create a CNAME at your DNS provider that points to <strong>{hostname}</strong>. This is the same value as Domainnamen.</p>
</div> </div>
</div> </div>
</section> </section>
+4
View File
@@ -161,6 +161,9 @@ def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None:
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1] dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
update_path = _path(body["update_url"]) update_path = _path(body["update_url"])
register_path = _path(body["registration_url"]) register_path = _path(body["registration_url"])
assert dashboard_secret.startswith("karl_")
assert update_path.rsplit("/", 1)[1].startswith("karl_")
assert register_path.rsplit("/", 1)[1].startswith("karl_")
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}") pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
assert pending.status_code == 200 assert pending.status_code == 200
@@ -214,6 +217,7 @@ def test_hostname_api_can_rotate_update_url(tmp_path: Path) -> None:
assert rotated.status_code == 200 assert rotated.status_code == 200
assert rotated.json()["hostname"] == body["hostname"] assert rotated.json()["hostname"] == body["hostname"]
assert rotated.json()["update_url"] != body["update_url"] assert rotated.json()["update_url"] != body["update_url"]
assert new_update_path.rsplit("/", 1)[1].startswith("karl_")
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404 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 assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
+9 -7
View File
@@ -28,8 +28,9 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert response.status_code == 200 assert response.status_code == 200
assert "/m/" in str(response.url) assert "/m/" in str(response.url)
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "CNAME target:" in response.text assert "Domainnamen:" in response.text
assert "Register URL:" in response.text assert "CNAME target:" not in response.text
assert "Register URL:" not in response.text
assert "Router setup." in response.text assert "Router setup." in response.text
assert "Use your own DNS name." in response.text assert "Use your own DNS name." in response.text
assert "Manual updates" in response.text assert "Manual updates" in response.text
@@ -40,7 +41,7 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "Secret:" in response.text assert "Secret:" in response.text
assert ".ddns.example.net" in response.text assert ".ddns.example.net" in response.text
assert "/api/v1/router/update/" in response.text assert "/api/v1/router/update/" in response.text
assert "/api/v1/router/register/" in response.text assert "/api/v1/router/register/" not in response.text
assert "/m/" in response.text assert "/m/" in response.text
assert 'type="password"' not in response.text assert 'type="password"' not in response.text
assert "Copy setup values" not in response.text assert "Copy setup values" not in response.text
@@ -79,8 +80,9 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
assert response.status_code == 200 assert response.status_code == 200
assert "/m/" in str(response.url) assert "/m/" in str(response.url)
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "CNAME target:" in response.text assert "Domainnamen:" in response.text
assert "Register URL:" in response.text assert "CNAME target:" not in response.text
assert "Register URL:" not in response.text
assert "Private dashboard:" in response.text assert "Private dashboard:" in response.text
assert "Pending setup" in response.text assert "Pending setup" in response.text
assert "Register hostname" in response.text assert "Register hostname" in response.text
@@ -89,8 +91,8 @@ def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: P
assert "Copy setup values" not in response.text assert "Copy setup values" not in response.text
assert "Paste this exact URL into the FRITZ!Box" in response.text assert "Paste this exact URL into the FRITZ!Box" in response.text
assert "Copy manual update commands" in response.text assert "Copy manual update commands" in response.text
assert response.text.count("copy-field-secret") >= 3 assert response.text.count("copy-field-secret") >= 2
assert response.text.count("copy-field-public") >= 2 assert response.text.count("copy-field-public") >= 1
assert ".ddns.example.net" in response.text assert ".ddns.example.net" in response.text
assert 'type="password"' not in response.text assert 'type="password"' not in response.text