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/
.ruff_cache/
*.sqlite3
*.sqlite3-*
*.db
*.db-*
dist/
build/
*.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>
```
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
+19 -8
View File
@@ -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
<div class="container">
<p class="eyebrow">Credentials generated</p>
<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()}
<div class="d-flex gap-2 flex-wrap my-3">
{_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)}
</div>
<div class="d-grid gap-2">
{_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 """
<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>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>
"""
@@ -949,7 +957,7 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage
<div class="col-lg-6">
<p class="eyebrow">Router settings</p>
<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()}
<div class="d-flex gap-2 flex-wrap mt-3">
{_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); }}
+2
View File
@@ -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),
)
+11
View File
@@ -122,6 +122,17 @@ class DdnsService:
slug = urllib.parse.quote(account["update_slug"])
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:
base = self.settings.public_base_url.rstrip("/")
slug = urllib.parse.quote(account["management_slug"])
+67
View File
@@ -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"] == ["<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:
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)