Archived
Add one-box DDNS update URL
This commit is contained in:
1 parent
5d23bea7ed
commit
67477ab40f
6 files changed
+103
-8
No files matched your search
@@ -4,7 +4,9 @@ __pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.sqlite3
|
||||
*.sqlite3-*
|
||||
*.db
|
||||
*.db-*
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
@@ -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
@@ -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); }}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user