diff --git a/README.md b/README.md index 1a494e2..e2d286b 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ The generated FRITZ!Box URL uses the native placeholders: https://ddns.example.net/u/?myip=&myipv6= ``` +FRITZ!Box replaces `` and `` 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=&myip=&myipv6=` 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/?myip=203.0.113.10' + +# Explicit IPv6 update +curl -sS 'https://ddns.example.net/u/?myipv6=2001:db8::10' + +# Use the request source IP +curl -sS 'https://ddns.example.net/u/' +``` + +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=&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: diff --git a/router_dyndns/ddns.py b/router_dyndns/ddns.py index c251a78..ea65936 100644 --- a/router_dyndns/ddns.py +++ b/router_dyndns/ddns.py @@ -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"""
@@ -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")}
+ {_manual_update_panel(manual_commands)}
""" @@ -881,6 +883,31 @@ def _copy_button(label: str, value: str) -> str: return f'' +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""" +
+
+

Manual updates

+

FRITZ!Box replaces <ipaddr> and <ip6addr>, 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.

+
+
{safe_commands}
+ {_copy_button("Copy curl examples", commands)} +
+ """ + + 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 """ No router updates yet. """ @@ -972,6 +1000,7 @@ def _render_management_page(service: DdnsService, settings: DdnsSettings, manage {_copy_row("Kennwort (optional):", "unchanged; only shown when generated", "Secret")} + {_manual_update_panel(manual_commands)}
@@ -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; }} diff --git a/tests/test_ddns.py b/tests/test_ddns.py index d09ad56..5ee24e6 100644 --- a/tests/test_ddns.py +++ b/tests/test_ddns.py @@ -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