From 67477ab40fe1a8a91837d240c912e4011ffdae68 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Wed, 20 May 2026 19:29:23 +0200 Subject: [PATCH] Add one-box DDNS update URL --- .gitignore | 2 ++ README.md | 2 ++ router_dyndns/ddns.py | 27 +++++++++----- router_dyndns/ddns_api.py | 2 ++ router_dyndns/ddns_service.py | 11 ++++++ tests/test_ddns.py | 67 +++++++++++++++++++++++++++++++++++ 6 files changed, 103 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 6a3e92a..64aef47 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ __pycache__/ .pytest_cache/ .ruff_cache/ *.sqlite3 +*.sqlite3-* *.db +*.db-* dist/ build/ *.egg-info/ diff --git a/README.md b/README.md index d087b80..d6e0f7a 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ The generated FRITZ!Box URL uses the native placeholders: https://ddns.example.net/u/?myip=&myipv6= ``` +This URL is enough for routers that only expose a single update URL field. RouterPulse also shows a `One-box update URL` that embeds `hostname`, `username`, and `password` as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets. + The compatibility endpoint is also available: ```text diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index c170274..914b042 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -321,6 +321,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: def index() -> str: return _render_public_home(service, settings, None, None) + @app.get("/magic", include_in_schema=False) + def magic_form() -> RedirectResponse: + return RedirectResponse("/#create", status_code=303) + @app.post("/magic", response_class=HTMLResponse) async def magic_account(request: Request) -> str: form = await _request_form(request, settings) @@ -457,9 +461,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI: notice = _admin_secret_panel( "Router password rotated", [ - _copy_row("Domainnamen:", str(rotated["hostname"])), - _copy_row("Benutzername:", str(rotated["username"])), - _copy_row("Kennwort:", str(rotated["password"])), + _copy_row("Domainnamen:", str(rotated["hostname"]), "Public"), + _copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"), + _copy_row("Benutzername:", str(rotated["username"]), "Public"), + _copy_row("Kennwort:", str(rotated["password"]), "Secret"), ], ) else: @@ -598,11 +603,11 @@ def _is_admin_path(path: str) -> bool: def _is_secret_response(request: Request) -> bool: path = request.url.path - if _is_admin_path(path) or path.startswith(("/m/", "/d/")): + if _is_admin_path(path) or path.startswith(("/m/", "/d/", "/u/")): return True if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")): return request.method in {"POST", "DELETE"} - return request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"} + return (request.method == "POST" and path in {"/magic", "/request-domain", "/verify-domain", "/accounts"}) or path == "/nic/update" def _content_security_policy(path: str) -> str: @@ -808,6 +813,7 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N username = created_account["username"] password = created_account["password"] update_url = service.fritz_update_url(created_account) + credentialed_update_url = service.credentialed_update_url(created_account) management_url = service.magic_management_url(created_account) cname_record = f"your-subdomain.example.com. CNAME {hostname}." fritzbox_fields = _fritzbox_fields(update_url, hostname, username, password, management_url) @@ -816,14 +822,16 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N

Credentials generated

{html.escape(hostname)}

-

Save this now. The password and private status page are only shown on this screen. To use your own DNS name, create a CNAME from that name to this hostname.

+

Save this now. The password, private status page, and one-box update URL are only shown on this screen. To use your own DNS name, create a CNAME from that name to this hostname.

{_secret_notice()}
{_copy_button("Copy FRITZ!Box fields", fritzbox_fields)} + {_copy_button("Copy one-box update URL", credentialed_update_url)} {_copy_button("Copy CNAME record", cname_record)}
{_copy_row("Update-URL:", update_url, "Secret")} + {_copy_row("One-box update URL:", credentialed_update_url, "Secret")} {_copy_row("Domainnamen:", hostname, "Public")} {_copy_row("CNAME target:", hostname, "Public")} {_copy_row("Benutzername:", username, "Public")} @@ -896,7 +904,7 @@ def _secret_notice() -> str: return """
Public: Domainnamen and CNAME target can be shared in DNS. - Secret: Update-URL, Kennwort, private status page, and domain claim links control the hostname. + Secret: Update-URL, one-box update URL, Kennwort, private status page, and domain claim links control the hostname.
""" @@ -949,7 +957,7 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage

Router settings

FRITZ!Box fields

-

Use these values in the FRITZ!Box custom DynDNS provider fields. If you use your own DNS name, point it at this hostname with a CNAME.

+

Use the Update-URL directly if your router only has one URL field. The username and password are shown only when generated or rotated. If you use your own DNS name, point it at this hostname with a CNAME.

{_secret_notice()}
{_copy_button("Copy FRITZ!Box fields", fritzbox_fields)} @@ -1349,8 +1357,11 @@ def _page(title: str, body: str) -> str: .text-primary {{ color: var(--rp-ink) !important; }} .text-bg-primary {{ color: #ffffff !important; background-color: #000000 !important; }} .alert-info, .alert-warning {{ --bs-alert-color: var(--rp-charcoal); --bs-alert-bg: var(--rp-surface-alt); --bs-alert-border-color: var(--rp-line); }} + .copy-field {{ min-width: 0; }} .copy-field .input-group-text {{ min-width: 150px; color: var(--rp-muted); font-size: 13px; }} .copy-label {{ display: inline-flex; align-items: center; justify-content: space-between; gap: 8px; }} + .copy-field .form-control {{ min-width: 0; }} + .copy-field code.form-control {{ width: 1%; flex: 1 1 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }} .copy-field code {{ min-height: 40px; margin: 0; color: var(--rp-ink); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 13px; line-height: 1.6; background: var(--rp-surface-alt); }} .admin-table-scroll {{ max-height: 560px; overflow: auto; }} .admin-table-scroll thead th {{ position: sticky; top: 0; z-index: 1; background: var(--rp-surface); }} diff --git a/router_dyndns/ddns_api.py b/router_dyndns/ddns_api.py index a7bed5c..f167177 100644 --- a/router_dyndns/ddns_api.py +++ b/router_dyndns/ddns_api.py @@ -22,6 +22,7 @@ class HostnameCredentials(BaseModel): username: str password: str update_url: str + credentialed_update_url: str management_url: str @@ -227,6 +228,7 @@ def _credentials_response(service: DdnsService, account: dict[str, str]) -> Host username=account["username"], password=account["password"], update_url=service.fritz_update_url(account), + credentialed_update_url=service.credentialed_update_url(account), management_url=service.magic_management_url(account), ) diff --git a/router_dyndns/ddns_service.py b/router_dyndns/ddns_service.py index 05cd98d..1b4972e 100644 --- a/router_dyndns/ddns_service.py +++ b/router_dyndns/ddns_service.py @@ -122,6 +122,17 @@ class DdnsService: slug = urllib.parse.quote(account["update_slug"]) return f"{base}/u/{slug}?myip=&myipv6=" + def credentialed_update_url(self, account: dict[str, str]) -> str: + base = self.settings.public_base_url.rstrip("/") + hostname = urllib.parse.quote(account["hostname"]) + username = urllib.parse.quote(account["username"]) + password = urllib.parse.quote(account["password"]) + return ( + f"{base}/nic/update?hostname={hostname}" + f"&username={username}&password={password}" + "&myip=&myipv6=" + ) + def magic_management_url(self, account: dict[str, str]) -> str: base = self.settings.public_base_url.rstrip("/") slug = urllib.parse.quote(account["management_slug"]) diff --git a/tests/test_ddns.py b/tests/test_ddns.py index a9f6779..d944b7c 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -1,6 +1,7 @@ import re import time from pathlib import Path +from urllib.parse import parse_qs, urlparse from fastapi.testclient import TestClient @@ -96,6 +97,57 @@ def test_generated_account_can_update_own_hostname(tmp_path: Path) -> None: assert slug_response.text == "good 203.0.113.10" +def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None: + database_path = tmp_path / "ddns.sqlite3" + app = make_app( + DdnsSettings( + database_path=database_path, + admin_password="admin", + public_base_url="http://ddns.example.net", + hostname_suffix="ddns.example.net", + ) + ) + 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"] == [""] + assert params["myipv6"] == [""] + + +def test_one_box_update_url_can_authenticate_with_query_credentials(tmp_path: Path) -> None: + database_path = tmp_path / "ddns.sqlite3" + store = DdnsStore(database_path) + account = store.create_account("home.example.net", "alice") + settings = DdnsSettings( + database_path=database_path, + admin_password="admin", + public_base_url="http://ddns.example.net", + ) + app = make_app(settings) + client = TestClient(app) + one_box_url = ddns_service.DdnsService(settings, store).credentialed_update_url(account) + parsed = urlparse(one_box_url) + params = parse_qs(parsed.query) + + params["myip"] = ["203.0.113.11"] + response = client.get(parsed.path, params={key: value[0] for key, value in params.items()}) + + assert response.status_code == 200 + assert response.text == "good 203.0.113.11" + assert response.headers["cache-control"] == "no-store" + + def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) @@ -290,6 +342,8 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None: ) 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:" in password.text @@ -308,6 +362,7 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) assert response.status_code == 200 assert "Update-URL:" in response.text + assert "One-box update URL:" in response.text assert "CNAME target:" in response.text assert "Copy FRITZ!Box fields" in response.text assert "Copy CNAME record" in response.text @@ -315,6 +370,7 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) assert "Secret:" in response.text assert ".ddns.example.net" in response.text assert "/u/" in response.text + assert "/nic/update?" in response.text assert "/m/" in response.text @@ -333,6 +389,7 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_ assert response.status_code == 200 assert "Update-URL:" in response.text + assert "One-box update URL:" in response.text assert "CNAME target:" in response.text assert "Private status page:" in response.text assert "Copy FRITZ!Box fields" in response.text @@ -342,6 +399,16 @@ 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: + 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" + + def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path)