Document manual DDNS update flow

This commit is contained in:
StasonJatham committed 2026-05-20 19:34:52 +02:00
1 parent f865bc28c7
commit 62e5724318
3 files changed
+61

No files matched your search

+23
View File
@@ -160,6 +160,8 @@ The generated FRITZ!Box URL uses the native placeholders:
https://ddns.example.net/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>
```
FRITZ!Box replaces `<ipaddr>` and `<ip6addr>` with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. RouterPulse returns DynDNS-style responses such as `good 203.0.113.10`, `nochg 203.0.113.10`, `badauth`, or `nohost`.
This URL is enough for routers that only expose a single update URL field and do not send separate credentials. 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:
@@ -170,6 +172,27 @@ https://ddns.example.net/nic/update?hostname=<domain>&myip=<ipaddr>&myipv6=<ip6a
Prefer `/u/<slug>` for managed service use because the router request cannot change the hostname.
## Manual Updates And Automation
Every generated hostname can be updated with curl, cron, a shell script, Home Assistant, OpenWrt, pfSense, or any client that can send an HTTP GET request:
```bash
# Explicit IPv4 update
curl -sS 'https://ddns.example.net/u/<random-update-slug>?myip=203.0.113.10'
# Explicit IPv6 update
curl -sS 'https://ddns.example.net/u/<random-update-slug>?myipv6=2001:db8::10'
# Use the request source IP
curl -sS 'https://ddns.example.net/u/<random-update-slug>'
```
The `/nic/update` compatibility endpoint also works for clients that expect hostname and credentials in the URL:
```bash
curl -sS 'https://ddns.example.net/nic/update?hostname=home.example.net&username=router&password=<password>&myip=203.0.113.10'
```
## Use Your Own Subdomain With CNAME
Most users do not need domain verification. Generate a RouterPulse hostname first, then create a CNAME at your DNS provider:
+30
View File
@@ -817,6 +817,7 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
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)
manual_commands = _manual_update_commands(update_url)
return f"""
<section class="section success-section">
<div class="container">
@@ -838,6 +839,7 @@ def _credentials_panel(service: DdnsService, created_account: dict[str, str] | N
{_copy_row("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")}
</div>
{_manual_update_panel(manual_commands)}
</div>
</section>
"""
@@ -881,6 +883,31 @@ def _copy_button(label: str, value: str) -> str:
return f'<button type="button" class="btn btn-outline-primary rounded-pill" data-copy="{safe_value}">{safe_label}</button>'
def _manual_update_commands(update_url: str) -> str:
update_base = update_url.split("?", 1)[0]
return "\n".join(
[
f"curl -sS '{update_base}?myip=203.0.113.10'",
f"curl -sS '{update_base}?myipv6=2001:db8::10'",
f"curl -sS '{update_base}'",
]
)
def _manual_update_panel(commands: str) -> str:
safe_commands = html.escape(commands)
return f"""
<div class="card card-body gap-3 mt-4">
<div>
<h3>Manual updates</h3>
<p class="section-copy">FRITZ!Box replaces <code>&lt;ipaddr&gt;</code> and <code>&lt;ip6addr&gt;</code>, then sends a GET request to the Update-URL. Scripts and other routers can do the same with curl. If no IP parameter is sent, RouterPulse uses the request source IP.</p>
</div>
<pre class="manual-code mb-0"><code>{safe_commands}</code></pre>
{_copy_button("Copy curl examples", commands)}
</div>
"""
def _fritzbox_fields(
update_url: str,
hostname: str,
@@ -935,6 +962,7 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage
updated = html.escape(str(account.get("updated_at") or "Never"))
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
cname_record = f"your-subdomain.example.com. CNAME {hostname}."
manual_commands = _manual_update_commands(update_url)
history_rows = "\n".join(_user_event_row(event) for event in service.store.list_update_events_for_hostname(str(account["hostname"]), 20)) or """
<tr><td colspan="5" class="empty">No router updates yet.</td></tr>
"""
@@ -972,6 +1000,7 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage
{_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div>
</div>
{_manual_update_panel(manual_commands)}
</div>
</section>
<section class="section">
@@ -1363,6 +1392,7 @@ def _page(title: str, body: str) -> str:
.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); }}
.manual-code {{ overflow-x: auto; padding: 16px; border: 1px solid var(--rp-line); border-radius: 12px; color: var(--rp-ink); background: var(--rp-surface-alt); font: 400 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }}
.admin-table-scroll {{ max-height: 560px; overflow: auto; }}
.admin-table-scroll thead th {{ position: sticky; top: 0; z-index: 1; background: var(--rp-surface); }}
.table-page-size {{ width: auto; min-width: 76px; }}
+8
View File
@@ -368,6 +368,10 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
assert "2001:db8::10" in response.text
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
@@ -398,6 +402,8 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
assert "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "FRITZ!Box replaces" in response.text
assert "Copy curl examples" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
@@ -428,6 +434,8 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
assert "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
assert "Secret:" in management.text
assert "Router updates" in management.text