From c19e5170a0f8311ece4a652eedb894efa0c52092 Mon Sep 17 00:00:00 2001 From: StasonJatham Date: Fri, 22 May 2026 16:14:59 +0200 Subject: [PATCH] Allow registration before first IP update --- README.md | 2 +- karldns/routers/management.py | 2 +- karldns/services/ddns.py | 25 +++++++++++++------------ karldns/web/templates_public.py | 2 +- tests/test_api.py | 18 +++++++++++++----- 5 files changed, 29 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 6e39ecf..0353383 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ Example: curl -sS -X POST https://karldns.de/api/v1/hostnames ``` -The create response includes `registration_url`, `update_url`, and `dashboard_url`. Save the returned secrets, then `POST` the registration URL before using the update URL. Registration publishes the initial DNS record from the request IP; scripts may also pass `myip` or `myipv6` on the registration URL. Pending hostnames that are never registered are cleaned up automatically. +The create response includes `registration_url`, `update_url`, and `dashboard_url`. Save the returned secrets, then `POST` the registration URL before using the update URL. Registration publishes an initial DNS record when a public request IP or explicit `myip`/`myipv6` value is available; otherwise the first router update publishes the record. Pending hostnames that are never registered are cleaned up automatically. Use the dashboard secret to rotate the update URL: diff --git a/karldns/routers/management.py b/karldns/routers/management.py index 6daa4da..3f6d361 100644 --- a/karldns/routers/management.py +++ b/karldns/routers/management.py @@ -84,7 +84,7 @@ async def activate_dashboard( management_slug, account, dashboard_csrf_token(management_slug, account), - "Registration needs a public source IP. Try again from your home connection or use the API with myip.", + "The supplied IP address is invalid. Leave IP fields empty and let the router send the first update.", client_ip(request, settings), ), status_code=400, diff --git a/karldns/services/ddns.py b/karldns/services/ddns.py index 8a1a6e8..10ecf06 100644 --- a/karldns/services/ddns.py +++ b/karldns/services/ddns.py @@ -72,21 +72,21 @@ class DdnsService: ipaddr: str | None, myipv6: str | None, ip6addr: str | None, - ) -> None: + ) -> bool: if not dns_backend_configured(self.settings): - return + return False source_ip = client_ip(request, self.settings) source_fallback = source_ip if _is_global_ip(source_ip) else "" - try: - ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips( - source_fallback, - myip, - ipaddr, - myipv6, - ip6addr, - ) - except BadIpError as exc: - raise BadIpError("registration needs a public source IP or myip/myipv6 parameter") from exc + supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr)) + if not supplied_ip and not source_fallback: + return False + ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips( + source_fallback, + myip, + ipaddr, + myipv6, + ip6addr, + ) lock = self._lock_for(hostname) with lock: planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6) @@ -115,6 +115,7 @@ class DdnsService: raise result = self.store.commit_update_result(planned) self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "initial dns registered", source_ip) + return True def apply_update( self, diff --git a/karldns/web/templates_public.py b/karldns/web/templates_public.py index 1f3a0a0..fd45fa5 100644 --- a/karldns/web/templates_public.py +++ b/karldns/web/templates_public.py @@ -183,7 +183,7 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)

Endpoints

  1. POST /api/v1/hostnames. Create a pending hostname and private setup values. The response includes registration_url.
  2. -
  3. GET or POST /api/v1/router/register/<karl_secret>. Register the hostname after secrets are saved. Uses the request IP, or myip/myipv6, for the initial DNS record.
  4. +
  5. GET or POST /api/v1/router/register/<karl_secret>. Register the hostname after secrets are saved. Publishes an initial DNS record when a public request IP or explicit myip/myipv6 value is available; otherwise the first router update publishes it.
  6. GET or POST /api/v1/router/update/<karl_secret>. Router-friendly update endpoint. Supports myip, ipaddr, myipv6, and ip6addr.
  7. GET /api/v1/hostnames/<karl_secret>. Read hostname status and recent history.
  8. POST /api/v1/hostnames/<karl_secret>/rotate-update-url. Replace the private router update URL.
  9. diff --git a/tests/test_api.py b/tests/test_api.py index 7f8d1cf..aac4fcf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -273,8 +273,9 @@ def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path assert published[0].ipv4 == "203.0.113.77" -def test_registration_requires_ip_when_provider_source_ip_is_not_public(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: None) +def test_registration_without_public_source_ip_waits_for_first_router_update(tmp_path: Path, monkeypatch) -> None: + published = [] + monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result)) app = make_app( DdnsSettings( database_path=tmp_path / "ddns.sqlite3", @@ -289,12 +290,19 @@ def test_registration_requires_ip_when_provider_source_ip_is_not_public(tmp_path client = TestClient(app) body = client.post("/api/v1/hostnames").json() + update_path = _path(body["update_url"]) registered = client.post(_path(body["registration_url"])) status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}") + update = client.get(update_path, params={"myip": "203.0.113.80"}) - assert registered.status_code == 400 - assert registered.text.startswith("badip registration needs a public source IP") - assert status.json()["status"] == "pending" + assert registered.status_code == 200 + assert registered.text == "good registered" + assert status.json()["status"] == "active" + assert status.json()["ipv4"] is None + assert update.status_code == 200 + assert update.text == "good 203.0.113.80" + assert len(published) == 1 + assert published[0].ipv4 == "203.0.113.80" def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None: