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> 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. 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: 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. 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 ## 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: 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) 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)
manual_commands = _manual_update_commands(update_url)
return f""" return f"""
<section class="section success-section"> <section class="section success-section">
<div class="container"> <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("Kennwort (optional):", password, "Secret")}
{_copy_row("Private status page:", management_url, "Secret")} {_copy_row("Private status page:", management_url, "Secret")}
</div> </div>
{_manual_update_panel(manual_commands)}
</div> </div>
</section> </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>' 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( def _fritzbox_fields(
update_url: str, update_url: str,
hostname: 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")) updated = html.escape(str(account.get("updated_at") or "Never"))
fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated") fritzbox_fields = _fritzbox_fields(update_url, hostname, username, "unchanged; only shown when generated")
cname_record = f"your-subdomain.example.com. CNAME {hostname}." 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 """ 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> <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")} {_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")}
</div> </div>
</div> </div>
{_manual_update_panel(manual_commands)}
</div> </div>
</section> </section>
<section class="section"> <section class="section">
@@ -1363,6 +1392,7 @@ def _page(title: str, body: str) -> str:
.copy-field .form-control {{ min-width: 0; }} .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.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); }}
.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 {{ 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); }}
.table-page-size {{ width: auto; min-width: 76px; }} .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 "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" 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 "Public:" in response.text
assert "Secret:" in response.text assert "Secret:" in response.text
assert ".ddns.example.net" 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 "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" 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("Secret") >= 3
assert response.text.count("Public") >= 3 assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text 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 "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" 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 "Public:" in management.text
assert "Secret:" in management.text assert "Secret:" in management.text
assert "Router updates" in management.text assert "Router updates" in management.text