Simplify onboarding with magic domain claims

This commit is contained in:
StasonJatham committed 2026-05-20 17:46:10 +02:00
1 parent 7ce4f6816f
commit 86060d6bdc
9 files changed
+108 -55

No files matched your search

+11 -14
View File
@@ -12,18 +12,14 @@ If you want a lightweight DuckDNS / Dynu / No-IP style service that you control
- **Self-hosted Dynamic DNS provider** for home labs, small ISPs, communities, and private infrastructure.
- **FRITZ!Box compatible**: copy the generated `Update-URL`, `Domainnamen`, `Benutzername`, and `Kennwort` straight into the German FRITZ!Box DynDNS form.
- **No account required for basic hostnames**: generate a cryptographically random provider hostname and private management link.
- **Custom domain support**: users enter a domain, add a DNS TXT record, then press a button to verify ownership before credentials are issued.
- **No registration required**: provider hostnames and custom-domain setup both use private magic links.
- **Custom domain support**: users enter a domain, save the private claim link, add a DNS TXT record, then press a button to verify ownership before credentials are issued.
- **Real DNS publishing** through **Cloudflare** or **RFC 2136** with A and AAAA support.
- **Small-server friendly**: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
- **API first**: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under `/api/v1`.
## Screenshots
### Dashboard
![RouterPulse dashboard for router hostnames](docs/screenshots/dashboard.png)
### Generate Router Credentials
![Generated FRITZ!Box DynDNS credentials](docs/screenshots/generated-credentials.png)
@@ -63,7 +59,7 @@ That means users always have a current hostname for VPN, remote access, self-hos
| Custom domains | TXT challenge verification before hostname creation |
| Persistence | SQLite with WAL mode |
| API | FastAPI, OpenAPI, Swagger UI, ReDoc |
| Auth model | Anonymous magic links for simple hostnames, login workspace for custom domains |
| Auth model | Private magic links for hostnames and custom-domain claims; optional account workspace for dashboards |
| Operations | Rate limiting, update logs, cleanup jobs, Docker, Caddy example |
## Quick Start
@@ -162,11 +158,12 @@ Prefer `/u/<slug>` for managed service use because the router request cannot cha
Custom domains are intentionally simple:
1. Enter the domain.
2. Add the generated TXT record at your DNS provider.
3. Press **I added it, check DNS**.
4. Create router credentials after the TXT record verifies.
2. Save the private claim link.
3. Add the generated TXT record at your DNS provider.
4. Press **I added it, check DNS**.
5. Create router credentials after the TXT record verifies.
RouterPulse stores the domain claim in SQLite and only creates hostnames below verified, publishable zones.
RouterPulse stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. The private claim link is the bearer credential for returning later after DNS propagation.
## HTTP API
@@ -177,9 +174,9 @@ The JSON API is available under `/api/v1`.
| `POST /api/v1/hostnames/magic` | Create an anonymous provider-owned hostname. |
| `GET /api/v1/management/{management_slug}` | Inspect a hostname by private management link. |
| `DELETE /api/v1/management/{management_slug}` | Delete a hostname and its DNS records. |
| `POST /api/v1/domains/challenges` | Create a TXT challenge. Requires login. |
| `POST /api/v1/domains/verify` | Verify a TXT challenge. Requires login. |
| `POST /api/v1/hostnames/custom` | Create a custom hostname below a verified domain. Requires login. |
| `POST /api/v1/domains/challenges` | Create a TXT challenge and private claim secret. |
| `POST /api/v1/domains/verify` | Verify a TXT challenge with the claim secret. |
| `POST /api/v1/hostnames/custom` | Create a custom hostname below a verified domain with the claim secret. |
| `GET /api/v1/updates/{update_slug}` | JSON update endpoint for routers or automation. |
Example:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

+37 -28
View File
@@ -181,6 +181,13 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
raise HTTPException(status_code=404, detail="not found")
return _render_management_page(service, settings, management_slug, account)
@app.get("/d/{claim_secret}", response_class=HTMLResponse)
def manage_domain_claim(claim_secret: str) -> str:
challenge = store.get_domain_challenge_by_secret(claim_secret)
if not challenge:
raise HTTPException(status_code=404, detail="not found")
return _render_public_home(service, settings, None, None, challenge)
@app.post("/m/{management_slug}/delete")
def delete_magic(management_slug: str) -> Response:
account = store.get_account_by_management_slug(management_slug)
@@ -212,46 +219,52 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
@app.post("/request-domain", response_class=HTMLResponse)
async def request_domain(request: Request) -> str:
user = await run_in_threadpool(current_user, request)
if not user:
raise HTTPException(status_code=401, detail="authentication required")
form = _parse_form(await request.body())
try:
challenge = await run_in_threadpool(service.create_domain_challenge, _first_form_value(form, "domain"), int(user["id"]))
challenge = await run_in_threadpool(
service.create_domain_challenge,
_first_form_value(form, "domain"),
int(user["id"]) if user else None,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge)
if user:
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, None, challenge)
return _render_public_home(service, settings, None, None, challenge)
@app.post("/verify-domain", response_class=HTMLResponse)
async def verify_domain(request: Request) -> str:
user = await run_in_threadpool(current_user, request)
if not user:
raise HTTPException(status_code=401, detail="authentication required")
form = _parse_form(await request.body())
domain = normalize_domain(_first_form_value(form, "domain"))
claim_secret = _first_form_value(form, "claim_secret")
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, int(user["id"]))
owner_user_id = int(user["id"]) if user else None
found, challenge = await run_in_threadpool(service.verify_domain, domain, claim_secret, owner_user_id)
if not found:
message = "TXT record not found yet. DNS propagation can take a few minutes."
if not user:
return _render_public_home(service, settings, None, message, challenge)
return _render_user_home(
service,
settings,
user,
store.list_accounts(int(user["id"])),
None,
"TXT record not found yet. DNS propagation can take a few minutes.",
message,
challenge,
)
if not user:
return _render_public_home(service, settings, None, "Domain verified. You can now create a hostname under it.", challenge)
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), None, "Domain verified. You can now create a hostname under it.", challenge)
@app.post("/accounts", response_class=HTMLResponse)
async def self_service_account(request: Request) -> str:
user = await run_in_threadpool(current_user, request)
if not user:
raise HTTPException(status_code=401, detail="authentication required")
form = _parse_form(await request.body())
mode = _first_form_value(form, "mode")
username = _first_form_value(form, "username") or None
if mode == "managed":
account = await run_in_threadpool(service.create_managed_account, username, int(user["id"]))
account = await run_in_threadpool(service.create_managed_account, username, int(user["id"]) if user else None)
else:
claim_secret = _first_form_value(form, "claim_secret")
account = await run_in_threadpool(
@@ -259,8 +272,10 @@ def make_app(settings: DdnsSettings | None = None) -> FastAPI:
_first_form_value(form, "hostname"),
claim_secret,
username,
int(user["id"]),
int(user["id"]) if user else None,
)
if not user:
return _render_public_home(service, settings, account, None)
return _render_user_home(service, settings, user, store.list_accounts(int(user["id"])), account, None, None)
@app.post("/register")
@@ -428,6 +443,7 @@ def _render_public_home(
suffix = html.escape(settings.hostname_suffix or "your DynDNS domain")
created_html = _credentials_panel(service, created_account)
message_html = _message_band(message) if message and not challenge else ""
challenge_html = _custom_domain_flow(service, challenge, message)
return _page(
"router-dyndns",
f"""
@@ -440,7 +456,7 @@ def _render_public_home(
<p class="lead">Create a secure update endpoint under {suffix}. Provider hostnames are anonymous; custom domains require DNS proof.</p>
<div class="hero-actions">
<a class="button" href="#create">Create hostname</a>
<a class="button secondary" href="/login">Use custom domain</a>
<a class="button secondary" href="#custom-domain">Use custom domain</a>
</div>
</div>
</section>
@@ -461,16 +477,7 @@ def _render_public_home(
</form>
</div>
</section>
<section class="section section-dark">
<div class="container split-row">
<div>
<p class="eyebrow">Custom domains</p>
<h2>Use your own domain after TXT verification.</h2>
<p class="section-copy">Enter your domain, add the TXT record we show you, then press the check button. We verify public DNS before credentials are issued.</p>
</div>
<a class="button secondary-on-dark" href="/login">Set up domain</a>
</div>
</section>
{challenge_html}
<footer class="footer">
<div class="container footer-links">
<a href="/docs">API docs</a>
@@ -563,8 +570,8 @@ def _render_auth_page(settings: DdnsSettings, message: str | None) -> str:
<section class="hero-band compact">
<div class="container hero-inner">
<p class="eyebrow">Custom domains</p>
<h1>Set up a verified domain.</h1>
<p class="lead">Create a small private workspace, enter your domain, add the TXT record we generate, then check public DNS.</p>
<h1>Optional account workspace.</h1>
<p class="lead">Most users do not need this. Magic links handle normal hostname and domain setup; accounts are only for people who want a persistent dashboard.</p>
</div>
</section>
{_message_band(message) if message else ""}
@@ -605,7 +612,7 @@ def _render_public_page(
) -> str:
if user:
return _render_user_home(service, settings, user, accounts, created_account, message, challenge)
return _render_auth_page(settings, message)
return _render_public_home(service, settings, created_account, message, challenge)
def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "") -> str:
@@ -615,7 +622,7 @@ def _top_nav(label: str | None = None, show_logout: bool = False, links: str = "
if show_logout:
account_html += '<form method="post" action="/logout"><button class="nav-button" type="submit">Logout</button></form>'
elif not links:
account_html += '<a class="nav-button" href="/login">Domain setup</a>'
account_html += '<a class="nav-button" href="/#custom-domain">Domain setup</a>'
return f"""
<nav class="top-nav">
<div class="container nav-inner">
@@ -637,6 +644,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
token = str(challenge["token"])
claim_secret = html.escape(str(challenge.get("claim_secret") or ""))
verification_name = service.verification_name(str(challenge["domain"]))
claim_url = service.domain_claim_url(challenge)
status = html.escape(message or "Add this TXT record at your DNS provider, then press the check button.")
challenge_html = f"""
<div class="step-card">
@@ -645,6 +653,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
<p class="note">{status}</p>
{_copy_row("TXT name", verification_name)}
{_copy_row("TXT value", token)}
{_copy_row("Private claim link", claim_url)}
<form method="post" action="/verify-domain" class="inline-form">
<input type="hidden" name="domain" value="{domain}">
<input type="hidden" name="claim_secret" value="{claim_secret}">
@@ -680,7 +689,7 @@ def _custom_domain_flow(service: DdnsService, challenge: dict[str, str | None] |
"""
return f"""
<section class="section section-dark">
<section class="section section-dark" id="custom-domain">
<div class="container">
<div class="section-heading">
<div>
+7 -13
View File
@@ -77,13 +77,7 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
user_id = verify_session_cookie(request.cookies.get("ddns_session"), settings.session_secret)
return store.get_user(user_id) if user_id is not None else None
def require_user(request: Request) -> dict[str, str | int]:
user = optional_user(request)
if not user:
raise HTTPException(status_code=401, detail="authentication required")
return user
user_dependency = Depends(require_user)
optional_user_dependency = Depends(optional_user)
@router.post(
"/hostnames/magic",
@@ -138,11 +132,11 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
)
async def create_domain_challenge(
payload: DomainChallengeRequest,
user: dict[str, str | int] = user_dependency,
user: dict[str, str | int] | None = optional_user_dependency,
) -> DomainChallengeResponse:
domain = normalize_domain(payload.domain)
try:
challenge = await run_in_threadpool(service.create_domain_challenge, domain, int(user["id"]))
challenge = await run_in_threadpool(service.create_domain_challenge, domain, int(user["id"]) if user else None)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="domain is already verified") from exc
return DomainChallengeResponse(
@@ -161,10 +155,10 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
)
async def verify_domain(
payload: VerifyDomainRequest,
user: dict[str, str | int] = user_dependency,
user: dict[str, str | int] | None = optional_user_dependency,
) -> VerifyDomainResponse:
domain = normalize_domain(payload.domain)
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret, int(user["id"]))
found, challenge = await run_in_threadpool(service.verify_domain, domain, payload.claim_secret, int(user["id"]) if user else None)
if not found:
return VerifyDomainResponse(
domain=domain,
@@ -189,14 +183,14 @@ def create_api_router(settings: DdnsSettings, store: DdnsStore, service: DdnsSer
)
async def create_custom_hostname(
payload: CustomHostnameRequest,
user: dict[str, str | int] = user_dependency,
user: dict[str, str | int] | None = optional_user_dependency,
) -> HostnameCredentials:
account = await run_in_threadpool(
service.create_custom_account,
payload.hostname,
payload.claim_secret,
payload.username,
int(user["id"]),
int(user["id"]) if user else None,
)
return _credentials_response(service, account)
+5
View File
@@ -136,6 +136,11 @@ class DdnsService:
slug = urllib.parse.quote(account["management_slug"])
return f"{base}/m/{slug}"
def domain_claim_url(self, challenge: dict[str, str | None]) -> str:
base = self.settings.public_base_url.rstrip("/")
secret = urllib.parse.quote(str(challenge["claim_secret"]))
return f"{base}/d/{secret}"
def random_managed_hostname(self) -> str:
suffix = self.settings.hostname_suffix.strip().lower().strip(".")
if not suffix:
+13
View File
@@ -243,6 +243,19 @@ class DdnsStore:
challenge = conn.execute(sql, params).fetchone()
return dict(challenge) if challenge else None
def get_domain_challenge_by_secret(self, claim_secret: str) -> dict[str, str | None] | None:
with self.connect() as conn:
conn.row_factory = sqlite3.Row
challenge = conn.execute(
"SELECT domain, token, verified_at FROM domain_challenges WHERE claim_hash = ?",
(hash_lookup_token(claim_secret),),
).fetchone()
if not challenge:
return None
result = dict(challenge)
result["claim_secret"] = claim_secret
return result
def is_domain_verified(
self,
domain: str,
+35
View File
@@ -1,3 +1,4 @@
import re
from pathlib import Path
from fastapi.testclient import TestClient
@@ -326,6 +327,40 @@ def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) ->
assert "Update-URL:" in response.text
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
challenge = client.post("/request-domain", data={"domain": "example.net"})
assert challenge.status_code == 200
assert "Private claim link" in challenge.text
assert "/d/" in challenge.text
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
claim_page = client.get(f"/d/{claim_secret}")
assert claim_page.status_code == 200
assert "TXT value" in claim_page.text
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create credentials" in verify.text
created = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)