Add one-box DDNS update URL

This commit is contained in:
StasonJatham committed 2026-05-20 19:29:23 +02:00
1 parent 5d23bea7ed
commit 67477ab40f
6 files changed
+103 -8

No files matched your search

+2
View File
@@ -4,7 +4,9 @@ __pycache__/
.pytest_cache/ .pytest_cache/
.ruff_cache/ .ruff_cache/
*.sqlite3 *.sqlite3
*.sqlite3-*
*.db *.db
*.db-*
dist/ dist/
build/ build/
*.egg-info/ *.egg-info/
+2
View File
@@ -158,6 +158,8 @@ The generated FRITZ!Box URL uses the native placeholders:
https://ddns.example.net/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr> https://ddns.example.net/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
``` ```
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: The compatibility endpoint is also available:
```text ```text
+19 -8
View File
@@ -321,6 +321,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
def index() -> str: def index() -> str:
return _render_public_home(service, settings, None, None) 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) @app.post("/magic", response_class=HTMLResponse)
async def magic_account(request: Request) -> str: async def magic_account(request: Request) -> str:
form = await _request_form(request, settings) form = await _request_form(request, settings)
@@ -457,9 +461,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
notice = _admin_secret_panel( notice = _admin_secret_panel(
"Router password rotated", "Router password rotated",
[ [
_copy_row("Domainnamen:", str(rotated["hostname"])), _copy_row("Domainnamen:", str(rotated["hostname"]), "Public"),
_copy_row("Benutzername:", str(rotated["username"])), _copy_row("One-box update URL:", service.credentialed_update_url(rotated), "Secret"),
_copy_row("Kennwort:", str(rotated["password"])), _copy_row("Benutzername:", str(rotated["username"]), "Public"),
_copy_row("Kennwort:", str(rotated["password"]), "Secret"),
], ],
) )
else: else:
@@ -598,11 +603,11 @@ def _is_admin_path(path: str) -> bool:
def _is_secret_response(request: Request) -> bool: def _is_secret_response(request: Request) -> bool:
path = request.url.path 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 return True
if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")): if path.startswith(("/api/v1/hostnames/", "/api/v1/domains/")):
return request.method in {"POST", "DELETE"} 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: 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"] username = created_account["username"]
password = created_account["password"] password = created_account["password"]
update_url = service.fritz_update_url(created_account) 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) management_url = service.magic_management_url(created_account)
cname_record = f"your-subdomain.example.com. CNAME {hostname}." cname_record = f"your-subdomain.example.com. CNAME {hostname}."
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, password, management_url) 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
<div class="container"> <div class="container">
<p class="eyebrow">Credentials generated</p> <p class="eyebrow">Credentials generated</p>
<h2>{html.escape(hostname)}</h2> <h2>{html.escape(hostname)}</h2>
<p class="section-copy">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.</p> <p class="section-copy">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.</p>
{_secret_notice()} {_secret_notice()}
<div class="d-flex gap-2 flex-wrap my-3"> <div class="d-flex gap-2 flex-wrap my-3">
{_copy_button("Copy FRITZ!Box fields", fritzbox_fields)} {_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_button("Copy CNAME record", cname_record)}
</div> </div>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
{_copy_row("Update-URL:", update_url, "Secret")} {_copy_row("Update-URL:", update_url, "Secret")}
{_copy_row("One-box update URL:", credentialed_update_url, "Secret")}
{_copy_row("Domainnamen:", hostname, "Public")} {_copy_row("Domainnamen:", hostname, "Public")}
{_copy_row("CNAME target:", hostname, "Public")} {_copy_row("CNAME target:", hostname, "Public")}
{_copy_row("Benutzername:", username, "Public")} {_copy_row("Benutzername:", username, "Public")}
@@ -896,7 +904,7 @@ def _secret_notice() -> str:
return """ return """
<div class="alert alert-info my-3 py-2 small" role="note"> <div class="alert alert-info my-3 py-2 small" role="note">
<strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS. <strong>Public:</strong> Domainnamen and CNAME target can be shared in DNS.
<strong>Secret:</strong> Update-URL, Kennwort, private status page, and domain claim links control the hostname. <strong>Secret:</strong> Update-URL, one-box update URL, Kennwort, private status page, and domain claim links control the hostname.
</div> </div>
""" """
@@ -949,7 +957,7 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage
<div class="col-lg-6"> <div class="col-lg-6">
<p class="eyebrow">Router settings</p> <p class="eyebrow">Router settings</p>
<h2>FRITZ!Box fields</h2> <h2>FRITZ!Box fields</h2>
<p class="section-copy">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.</p> <p class="section-copy">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.</p>
{_secret_notice()} {_secret_notice()}
<div class="d-flex gap-2 flex-wrap mt-3"> <div class="d-flex gap-2 flex-wrap mt-3">
{_copy_button("Copy FRITZ!Box fields", fritzbox_fields)} {_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-primary {{ color: var(--rp-ink) !important; }}
.text-bg-primary {{ color: #ffffff !important; background-color: #000000 !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); }} .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-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-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); }} .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 {{ max-height: 560px; overflow: auto; }}
.admin-table-scroll thead th {{ position: sticky; top: 0; z-index: 1; background: var(--rp-surface); }} .admin-table-scroll thead th {{ position: sticky; top: 0; z-index: 1; background: var(--rp-surface); }}
+2
View File
@@ -22,6 +22,7 @@ class HostnameCredentials(BaseModel):
username: str username: str
password: str password: str
update_url: str update_url: str
credentialed_update_url: str
management_url: str management_url: str
@@ -227,6 +228,7 @@ def _credentials_response(service: DdnsService, account: dict[str, str]) -> Host
username=account["username"], username=account["username"],
password=account["password"], password=account["password"],
update_url=service.fritz_update_url(account), update_url=service.fritz_update_url(account),
credentialed_update_url=service.credentialed_update_url(account),
management_url=service.magic_management_url(account), management_url=service.magic_management_url(account),
) )
+11
View File
@@ -122,6 +122,17 @@ class DdnsService:
slug = urllib.parse.quote(account["update_slug"]) slug = urllib.parse.quote(account["update_slug"])
return f"{base}/u/{slug}?myip=<ipaddr>&myipv6=<ip6addr>" return f"{base}/u/{slug}?myip=<ipaddr>&myipv6=<ip6addr>"
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=<ipaddr>&myipv6=<ip6addr>"
)
def magic_management_url(self, account: dict[str, str]) -> str: def magic_management_url(self, account: dict[str, str]) -> str:
base = self.settings.public_base_url.rstrip("/") base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"]) slug = urllib.parse.quote(account["management_slug"])
+67
View File
@@ -1,6 +1,7 @@
import re import re
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse
from fastapi.testclient import TestClient 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" 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"] == ["<ipaddr>"]
assert params["myipv6"] == ["<ip6addr>"]
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: def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) 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 password.status_code == 200
assert "Router password rotated" in password.text 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 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 response.status_code == 200
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text assert "CNAME target:" in response.text
assert "Copy FRITZ!Box fields" in response.text assert "Copy FRITZ!Box fields" in response.text
assert "Copy CNAME record" 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 "Secret:" in response.text
assert ".ddns.example.net" in response.text assert ".ddns.example.net" in response.text
assert "/u/" in response.text assert "/u/" in response.text
assert "/nic/update?" in response.text
assert "/m/" 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 response.status_code == 200
assert "Update-URL:" in response.text assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "CNAME target:" in response.text assert "CNAME target:" in response.text
assert "Private status page:" in response.text assert "Private status page:" in response.text
assert "Copy FRITZ!Box fields" 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 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: def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3" database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path) store = DdnsStore(database_path)