diff --git a/README.md b/README.md index 0353383..00a999f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ KarlDNS lets you host your own managed DynDNS service: 3. KarlDNS shows the exact router settings and private values. 4. The user confirms they saved the values, which registers the hostname. 5. The router periodically calls the update URL with its current public IPv4/IPv6 address. -6. KarlDNS publishes the matching DNS records through your configured DNS backend. +6. KarlDNS publishes the matching DNS records through your configured DNS backend. If no public IP is visible during registration, it publishes a temporary `192.0.2.1` A record until the router sends its first real update. 7. If the user wants their own DNS name, they can create a CNAME to the generated hostname. That means users always have a current hostname for VPN, remote access, self-hosted apps, home servers, NAS devices, and lab networks. @@ -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 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. +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. If no public IP is visible, KarlDNS publishes a temporary `192.0.2.1` A record and replaces it on the first router update. Pending hostnames that are never registered are cleaned up automatically. Use the dashboard secret to rotate the update URL: diff --git a/karldns/services/ddns.py b/karldns/services/ddns.py index 10ecf06..8d36141 100644 --- a/karldns/services/ddns.py +++ b/karldns/services/ddns.py @@ -20,6 +20,7 @@ from ..settings import DdnsSettings from ..store import DdnsStore LOGGER = logging.getLogger(__name__) +INITIAL_PLACEHOLDER_IPV4 = "192.0.2.1" @dataclass(slots=True) @@ -79,14 +80,20 @@ class DdnsService: source_fallback = source_ip if _is_global_ip(source_ip) else "" 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, - ) + ipv4 = INITIAL_PLACEHOLDER_IPV4 + ipv6 = None + update_ipv4 = True + update_ipv6 = False + detail = "initial placeholder dns registered" + else: + ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips( + source_fallback, + myip, + ipaddr, + myipv6, + ip6addr, + ) + detail = "initial dns registered" lock = self._lock_for(hostname) with lock: planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6) @@ -114,7 +121,7 @@ class DdnsService: self.store.log_update_event(hostname, ipv4, ipv6, DNS_FAILED, f"{type(exc).__name__}: {exc}", source_ip) raise result = self.store.commit_update_result(planned) - self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, "initial dns registered", source_ip) + self.store.log_update_event(hostname, result.ipv4, result.ipv6, UPDATED, detail, source_ip) return True def apply_update( diff --git a/karldns/web/templates_public.py b/karldns/web/templates_public.py index fd45fa5..d431ce0 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. Publishes an initial DNS record when a public request IP or explicit myip/myipv6 value is available; otherwise the first router update publishes it.
  4. +
  5. GET or POST /api/v1/router/register/<karl_secret>. Register the hostname after secrets are saved. Publishes an initial DNS record from a public request IP or explicit myip/myipv6 value. If no public IP is visible, publishes a temporary 192.0.2.1 A record until the first router update.
  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 aac4fcf..d312367 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -273,7 +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_without_public_source_ip_waits_for_first_router_update(tmp_path: Path, monkeypatch) -> None: +def test_registration_without_public_source_ip_publishes_placeholder_until_first_router_update( + tmp_path: Path, monkeypatch +) -> None: published = [] monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result)) app = make_app( @@ -298,11 +300,14 @@ def test_registration_without_public_source_ip_waits_for_first_router_update(tmp assert registered.status_code == 200 assert registered.text == "good registered" assert status.json()["status"] == "active" - assert status.json()["ipv4"] is None + assert status.json()["ipv4"] == "192.0.2.1" 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" + assert len(published) == 2 + assert published[0].ipv4 == "192.0.2.1" + assert published[0].update_ipv4 is True + assert published[0].update_ipv6 is False + assert published[1].ipv4 == "203.0.113.80" def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None: