Allow registration before first IP update

This commit is contained in:
StasonJatham committed 2026-05-22 16:14:59 +02:00
1 parent 9f5cee8a0e
commit c19e5170a0
5 files changed
+29 -20

No files matched your search

+1 -1
View File
@@ -335,7 +335,7 @@ Example:
curl -sS -X POST https://karldns.de/api/v1/hostnames 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: Use the dashboard secret to rotate the update URL:
+1 -1
View File
@@ -84,7 +84,7 @@ async def activate_dashboard(
management_slug, management_slug,
account, account,
dashboard_csrf_token(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), client_ip(request, settings),
), ),
status_code=400, status_code=400,
+13 -12
View File
@@ -72,21 +72,21 @@ class DdnsService:
ipaddr: str | None, ipaddr: str | None,
myipv6: str | None, myipv6: str | None,
ip6addr: str | None, ip6addr: str | None,
) -> None: ) -> bool:
if not dns_backend_configured(self.settings): if not dns_backend_configured(self.settings):
return return False
source_ip = client_ip(request, self.settings) source_ip = client_ip(request, self.settings)
source_fallback = source_ip if _is_global_ip(source_ip) else "" source_fallback = source_ip if _is_global_ip(source_ip) else ""
try: supplied_ip = any(value and value.strip() for value in (myip, ipaddr, myipv6, ip6addr))
ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips( if not supplied_ip and not source_fallback:
source_fallback, return False
myip, ipv4, ipv6, update_ipv4, update_ipv6 = parse_update_ips(
ipaddr, source_fallback,
myipv6, myip,
ip6addr, ipaddr,
) myipv6,
except BadIpError as exc: ip6addr,
raise BadIpError("registration needs a public source IP or myip/myipv6 parameter") from exc )
lock = self._lock_for(hostname) lock = self._lock_for(hostname)
with lock: with lock:
planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6) planned = self.store.preview_update(hostname, ipv4, ipv6, update_ipv4=update_ipv4, update_ipv6=update_ipv6)
@@ -115,6 +115,7 @@ class DdnsService:
raise raise
result = self.store.commit_update_result(planned) 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, "initial dns registered", source_ip)
return True
def apply_update( def apply_update(
self, self,
+1 -1
View File
@@ -183,7 +183,7 @@ def _render_api_docs_page(settings: DdnsSettings, request_ip: str | None = None)
<h2>Endpoints</h2> <h2>Endpoints</h2>
<ol class="simple-steps text-start"> <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>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. Uses the request IP, or <code>myip</code>/<code>myipv6</code>, for the initial DNS record.</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/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 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>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> <li><strong>POST /api/v1/hostnames/&lt;karl_secret&gt;/rotate-update-url.</strong> Replace the private router update URL.</li>
+13 -5
View File
@@ -273,8 +273,9 @@ def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path
assert published[0].ipv4 == "203.0.113.77" 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: def test_registration_without_public_source_ip_waits_for_first_router_update(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: None) published = []
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
app = make_app( app = make_app(
DdnsSettings( DdnsSettings(
database_path=tmp_path / "ddns.sqlite3", 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) client = TestClient(app)
body = client.post("/api/v1/hostnames").json() body = client.post("/api/v1/hostnames").json()
update_path = _path(body["update_url"])
registered = client.post(_path(body["registration_url"])) registered = client.post(_path(body["registration_url"]))
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}") 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.status_code == 200
assert registered.text.startswith("badip registration needs a public source IP") assert registered.text == "good registered"
assert status.json()["status"] == "pending" 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: def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None: