Publish placeholder DNS on registration

This commit is contained in:
StasonJatham committed 2026-05-22 16:48:23 +02:00
1 parent 99c55405bc
commit c0d82eaf26
4 files changed
+28 -16

No files matched your search

+2 -2
View File
@@ -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:
+16 -9
View File
@@ -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(
+1 -1
View File
@@ -183,7 +183,7 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
<h2>Endpoints</h2>
<ol class="simple-steps text-start">
<li><strong>POST /api/v1/hostnames.</strong> Create a pending hostname and private setup values. The response includes <code>registration_url</code>.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong> Register the hostname after secrets are saved. Publishes an initial DNS record when a public request IP or explicit <code>myip</code>/<code>myipv6</code> value is available; otherwise the first router update publishes it.</li>
<li><strong>GET or POST /api/v1/router/register/&lt;karl_secret&gt;.</strong> Register the hostname after secrets are saved. Publishes an initial DNS record from a public request IP or explicit <code>myip</code>/<code>myipv6</code> value. If no public IP is visible, publishes a temporary <code>192.0.2.1</code> A record until the first router update.</li>
<li><strong>GET or POST /api/v1/router/update/&lt;karl_secret&gt;.</strong> Router-friendly update endpoint. Supports <code>myip</code>, <code>ipaddr</code>, <code>myipv6</code>, and <code>ip6addr</code>.</li>
<li><strong>GET /api/v1/hostnames/&lt;karl_secret&gt;.</strong> Read hostname status and recent history.</li>
<li><strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
+9 -4
View File
@@ -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: