Archived
Simplify onboarding with magic domain claims
This commit is contained in:
1 parent
7ce4f6816f
commit
86060d6bdc
9 files changed
+107
-54
No files matched your search
@@ -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
|
||||
|
||||

|
||||
|
||||
### Generate Router Credentials
|
||||
|
||||

|
||||
@@ -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 |
+36
-27
@@ -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
|
||||
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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user